Initial Motif project setup

- Web Audio + TypeScript procedural synthesis engine
- Role-based MIDI structure extraction
- Vite build tooling and development setup
- Core architecture: Engine, RoleMapper, SynthesisEngine
- Minimal UI for testing synthesis generation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
b1rdmania
2025-12-17 20:33:48 +00:00
commit 4b7836b925
11 changed files with 813 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
import type { NoteEvent, StructuralFeatures, MotifConfig, SynthLayer } from '../types';
import { MIDIProcessor } from '../midi/MIDIProcessor';
import { RoleMapper } from './RoleMapper';
import { SynthesisEngine } from '../synthesis/SynthesisEngine';
export class MotifEngine {
private audioContext: AudioContext | null = null;
private config: MotifConfig;
private midiProcessor: MIDIProcessor;
private roleMapper: RoleMapper;
private synthesisEngine: SynthesisEngine | null = null;
private currentFeatures: StructuralFeatures | null = null;
constructor() {
this.config = {
lookaheadTime: 0.1,
scheduleInterval: 25,
fadeTime: 0.05,
maxOscillators: 8
};
this.midiProcessor = new MIDIProcessor();
this.roleMapper = new RoleMapper();
}
async generateFromSong(songName: string): Promise<void> {
// For now, generate synthetic structure based on song name
// TODO: Implement MIDI search and fetching
const mockEvents = this.generateSyntheticMIDI(songName);
const features = this.midiProcessor.extractFeatures(mockEvents);
const roleAssignments = this.roleMapper.assignRoles(features, mockEvents);
this.currentFeatures = features;
// Initialize audio context and synthesis engine
if (!this.audioContext) {
this.audioContext = new AudioContext();
}
this.synthesisEngine = new SynthesisEngine(this.audioContext, this.config);
this.synthesisEngine.setupLayers(roleAssignments);
}
async play(): Promise<void> {
if (!this.audioContext || !this.synthesisEngine || !this.currentFeatures) {
throw new Error('No audio generated yet');
}
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume();
}
this.synthesisEngine.start();
}
stop(): void {
if (this.synthesisEngine) {
this.synthesisEngine.stop();
}
}
private generateSyntheticMIDI(songName: string): NoteEvent[] {
// Generate procedural MIDI based on song name hash
const hash = this.simpleHash(songName);
const events: NoteEvent[] = [];
// Create a simple 4/4 pattern with bass, harmony, and texture
const duration = 32; // 32 seconds
const beatsPerSecond = (120 + (hash % 60)) / 60; // Tempo 120-180 BPM
// Bass pattern (track 0)
for (let beat = 0; beat < duration * beatsPerSecond; beat += 1) {
if (beat % 4 === 0) { // On beat
events.push({
time: beat / beatsPerSecond,
duration: 0.5,
pitch: 36 + (hash % 12), // C2 + random root
velocity: 0.7 + (hash % 3) * 0.1,
track: 0
});
}
}
// Harmonic drone (track 1)
events.push({
time: 0,
duration: duration,
pitch: 48 + ((hash * 3) % 12), // C3 + harmonic interval
velocity: 0.3,
track: 1
});
// Textural elements (track 2)
for (let i = 0; i < 20; i++) {
events.push({
time: (hash * i) % duration,
duration: 0.2 + ((hash * i) % 10) * 0.1,
pitch: 60 + ((hash * i) % 24), // C4 + 2 octaves
velocity: 0.2 + ((hash * i) % 5) * 0.1,
track: 2
});
}
return events.sort((a, b) => a.time - b.time);
}
private simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash);
}
}
+71
View File
@@ -0,0 +1,71 @@
import type { NoteEvent, StructuralFeatures, RoleAssignment, Role } from '../types';
export class RoleMapper {
assignRoles(features: StructuralFeatures, events: NoteEvent[]): RoleAssignment[] {
const assignments: RoleAssignment[] = [];
const trackEvents = this.groupEventsByTrack(events);
for (const [trackId, trackNotes] of trackEvents) {
const role = this.determineRole(trackNotes, features);
const confidence = this.calculateConfidence(trackNotes, role);
assignments.push({
role,
sourceTrack: trackId,
events: trackNotes,
confidence
});
}
return assignments;
}
private groupEventsByTrack(events: NoteEvent[]): Map<number, NoteEvent[]> {
const tracks = new Map<number, NoteEvent[]>();
for (const event of events) {
if (!tracks.has(event.track)) {
tracks.set(event.track, []);
}
tracks.get(event.track)!.push(event);
}
return tracks;
}
private determineRole(events: NoteEvent[], features: StructuralFeatures): Role {
if (events.length === 0) return 'texture';
const avgPitch = events.reduce((sum, e) => sum + e.pitch, 0) / events.length;
const avgDuration = events.reduce((sum, e) => sum + e.duration, 0) / events.length;
const avgVelocity = events.reduce((sum, e) => sum + e.velocity, 0) / events.length;
// Bass: low register, rhythmic
if (avgPitch < 48 && avgDuration < 1.0) {
return 'bass';
}
// Drone: sustained notes
if (avgDuration > 2.0) {
return 'drone';
}
// Ostinato: repetitive short notes
if (avgDuration < 0.5 && events.length > 10) {
return 'ostinato';
}
// Accents: high velocity peaks
if (avgVelocity > 0.8) {
return 'accents';
}
return 'texture';
}
private calculateConfidence(events: NoteEvent[], role: Role): number {
// Simple confidence calculation based on how well events fit the role
// In a real implementation, this would be more sophisticated
return 0.7 + Math.random() * 0.3;
}
}
+78
View File
@@ -0,0 +1,78 @@
import { MotifEngine } from './core/MotifEngine';
class MotifApp {
private engine: MotifEngine;
private generateBtn: HTMLButtonElement;
private playBtn: HTMLButtonElement;
private stopBtn: HTMLButtonElement;
private songInput: HTMLInputElement;
private status: HTMLElement;
constructor() {
this.engine = new MotifEngine();
this.initializeUI();
this.setupEventListeners();
}
private initializeUI(): void {
this.generateBtn = document.getElementById('generateBtn') as HTMLButtonElement;
this.playBtn = document.getElementById('playBtn') as HTMLButtonElement;
this.stopBtn = document.getElementById('stopBtn') as HTMLButtonElement;
this.songInput = document.getElementById('songInput') as HTMLInputElement;
this.status = document.getElementById('status')!;
}
private setupEventListeners(): void {
this.generateBtn.addEventListener('click', () => this.handleGenerate());
this.playBtn.addEventListener('click', () => this.handlePlay());
this.stopBtn.addEventListener('click', () => this.handleStop());
this.songInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.handleGenerate();
}
});
}
private async handleGenerate(): Promise<void> {
const songName = this.songInput.value.trim();
if (!songName) return;
this.updateStatus('Generating structure...');
this.generateBtn.disabled = true;
try {
await this.engine.generateFromSong(songName);
this.updateStatus(`Generated: ${songName} - Ready to play`);
this.playBtn.disabled = false;
} catch (error) {
this.updateStatus(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
this.generateBtn.disabled = false;
}
}
private async handlePlay(): Promise<void> {
try {
await this.engine.play();
this.updateStatus('Playing...');
this.playBtn.disabled = true;
this.stopBtn.disabled = false;
} catch (error) {
this.updateStatus(`Playback error: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private handleStop(): void {
this.engine.stop();
this.updateStatus('Stopped');
this.playBtn.disabled = false;
this.stopBtn.disabled = true;
}
private updateStatus(message: string): void {
this.status.textContent = message;
}
}
new MotifApp();
+82
View File
@@ -0,0 +1,82 @@
import type { NoteEvent, StructuralFeatures } from '../types';
export class MIDIProcessor {
extractFeatures(events: NoteEvent[]): StructuralFeatures {
if (events.length === 0) {
return this.getDefaultFeatures();
}
const tempo = this.estimateTempo(events);
const totalDuration = Math.max(...events.map(e => e.time + e.duration));
const noteDensity = this.calculateDensity(events, totalDuration);
const registerDistribution = this.analyzeRegisterDistribution(events);
return {
tempo,
totalDuration,
noteDensity,
registerDistribution,
trackRoles: new Map()
};
}
private estimateTempo(events: NoteEvent[]): number {
// Simple tempo estimation based on note onset intervals
const onsets = events.map(e => e.time).sort((a, b) => a - b);
const intervals: number[] = [];
for (let i = 1; i < Math.min(onsets.length, 20); i++) {
intervals.push(onsets[i] - onsets[i - 1]);
}
if (intervals.length === 0) return 120;
// Find most common interval (simplified)
intervals.sort((a, b) => a - b);
const medianInterval = intervals[Math.floor(intervals.length / 2)];
// Convert to BPM (assuming quarter notes)
return 60 / Math.max(medianInterval, 0.1);
}
private calculateDensity(events: NoteEvent[], duration: number): number[] {
const windows = Math.ceil(duration / 4); // 4-second windows
const density = new Array(windows).fill(0);
for (const event of events) {
const windowIndex = Math.floor(event.time / 4);
if (windowIndex < windows) {
density[windowIndex]++;
}
}
return density;
}
private analyzeRegisterDistribution(events: NoteEvent[]): { low: number; mid: number; high: number } {
let low = 0, mid = 0, high = 0;
for (const event of events) {
if (event.pitch < 48) low++;
else if (event.pitch < 72) mid++;
else high++;
}
const total = events.length;
return {
low: low / total,
mid: mid / total,
high: high / total
};
}
private getDefaultFeatures(): StructuralFeatures {
return {
tempo: 120,
totalDuration: 30,
noteDensity: [4, 4, 4, 4, 4, 4, 4, 4],
registerDistribution: { low: 0.3, mid: 0.5, high: 0.2 },
trackRoles: new Map()
};
}
}
+252
View File
@@ -0,0 +1,252 @@
import type { RoleAssignment, MotifConfig, SynthLayer, Role } from '../types';
export class SynthesisEngine {
private audioContext: AudioContext;
private config: MotifConfig;
private masterGain: GainNode;
private layers: Map<Role, SynthLayer> = new Map();
private isPlaying = false;
private schedulerIntervalId: number | null = null;
private currentTime = 0;
private startTime = 0;
constructor(audioContext: AudioContext, config: MotifConfig) {
this.audioContext = audioContext;
this.config = config;
this.masterGain = audioContext.createGain();
this.masterGain.connect(audioContext.destination);
this.masterGain.gain.value = 0.3;
}
setupLayers(assignments: RoleAssignment[]): void {
// Clean up existing layers
this.cleanupLayers();
for (const assignment of assignments) {
const layer = this.createSynthLayer(assignment.role);
this.layers.set(assignment.role, layer);
}
}
start(): void {
if (this.isPlaying) return;
this.isPlaying = true;
this.startTime = this.audioContext.currentTime;
this.currentTime = 0;
// Start continuous layers (drone, texture)
this.startContinuousLayers();
// Start scheduler for rhythmic layers
this.schedulerIntervalId = window.setInterval(() => {
this.scheduleEvents();
}, this.config.scheduleInterval);
}
stop(): void {
if (!this.isPlaying) return;
this.isPlaying = false;
if (this.schedulerIntervalId) {
clearInterval(this.schedulerIntervalId);
this.schedulerIntervalId = null;
}
// Fade out all layers
this.fadeOutAllLayers();
}
private createSynthLayer(role: Role): SynthLayer {
const gainNode = this.audioContext.createGain();
const filterNode = this.audioContext.createBiquadFilter();
filterNode.connect(gainNode);
gainNode.connect(this.masterGain);
// Configure based on role
this.configureLayerForRole(gainNode, filterNode, role);
return {
role,
oscillators: [],
gainNode,
filterNode
};
}
private configureLayerForRole(gain: GainNode, filter: BiquadFilterNode, role: Role): void {
switch (role) {
case 'bass':
gain.gain.value = 0.4;
filter.type = 'lowpass';
filter.frequency.value = 200;
break;
case 'drone':
gain.gain.value = 0.2;
filter.type = 'bandpass';
filter.frequency.value = 400;
break;
case 'ostinato':
gain.gain.value = 0.3;
filter.type = 'highpass';
filter.frequency.value = 300;
break;
case 'texture':
gain.gain.value = 0.1;
filter.type = 'bandpass';
filter.frequency.value = 800;
break;
case 'accents':
gain.gain.value = 0.5;
filter.type = 'peaking';
filter.frequency.value = 1000;
break;
}
}
private startContinuousLayers(): void {
const droneLayer = this.layers.get('drone');
if (droneLayer) {
this.startDrone(droneLayer);
}
const textureLayer = this.layers.get('texture');
if (textureLayer) {
this.startTexture(textureLayer);
}
}
private startDrone(layer: SynthLayer): void {
const osc1 = this.audioContext.createOscillator();
const osc2 = this.audioContext.createOscillator();
osc1.frequency.value = 110; // A2
osc2.frequency.value = 110.5; // Slight detune
osc1.type = 'sawtooth';
osc2.type = 'sawtooth';
osc1.connect(layer.filterNode);
osc2.connect(layer.filterNode);
osc1.start();
osc2.start();
layer.oscillators.push(osc1, osc2);
}
private startTexture(layer: SynthLayer): void {
// Create evolving texture with multiple oscillators
for (let i = 0; i < 4; i++) {
setTimeout(() => {
if (!this.isPlaying) return;
this.addTextureOscillator(layer);
}, i * 2000);
}
}
private addTextureOscillator(layer: SynthLayer): void {
const osc = this.audioContext.createOscillator();
const envelope = this.audioContext.createGain();
osc.frequency.value = 220 + Math.random() * 880; // Random frequency
osc.type = 'triangle';
osc.connect(envelope);
envelope.connect(layer.filterNode);
// Slow attack and decay
envelope.gain.setValueAtTime(0, this.audioContext.currentTime);
envelope.gain.linearRampToValueAtTime(0.1, this.audioContext.currentTime + 4);
envelope.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + 8);
osc.start();
osc.stop(this.audioContext.currentTime + 8);
layer.oscillators.push(osc);
}
private scheduleEvents(): void {
if (!this.isPlaying) return;
const currentTime = this.audioContext.currentTime;
const scheduleUntil = currentTime + this.config.lookaheadTime;
// Schedule bass hits
this.scheduleBassHits(scheduleUntil);
// Schedule ostinato patterns
this.scheduleOstinato(scheduleUntil);
this.currentTime = (currentTime - this.startTime) % 32; // Loop every 32 seconds
}
private scheduleBassHits(scheduleUntil: number): void {
const bassLayer = this.layers.get('bass');
if (!bassLayer) return;
const beatInterval = 60 / 120; // 120 BPM
const nextBeat = Math.ceil(this.currentTime / beatInterval) * beatInterval;
const scheduleTime = this.startTime + nextBeat;
if (scheduleTime <= scheduleUntil && nextBeat % 1 < 0.1) { // On downbeat
this.triggerBassHit(bassLayer, scheduleTime);
}
}
private triggerBassHit(layer: SynthLayer, when: number): void {
const osc = this.audioContext.createOscillator();
const envelope = this.audioContext.createGain();
osc.frequency.value = 55; // A1
osc.type = 'square';
osc.connect(envelope);
envelope.connect(layer.filterNode);
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(0.8, when + 0.01);
envelope.gain.exponentialRampToValueAtTime(0.001, when + 0.5);
osc.start(when);
osc.stop(when + 0.5);
}
private scheduleOstinato(scheduleUntil: number): void {
// Simple ostinato pattern - implementation would be more sophisticated
const ostinatoLayer = this.layers.get('ostinato');
if (!ostinatoLayer) return;
// Implementation for rhythmic patterns would go here
}
private fadeOutAllLayers(): void {
const fadeTime = this.config.fadeTime;
const when = this.audioContext.currentTime;
for (const layer of this.layers.values()) {
layer.gainNode.gain.linearRampToValueAtTime(0, when + fadeTime);
}
setTimeout(() => {
this.cleanupLayers();
}, fadeTime * 1000 + 100);
}
private cleanupLayers(): void {
for (const layer of this.layers.values()) {
for (const osc of layer.oscillators) {
try {
osc.stop();
osc.disconnect();
} catch (e) {
// Oscillator might already be stopped
}
}
layer.gainNode.disconnect();
layer.filterNode.disconnect();
}
this.layers.clear();
}
}
+38
View File
@@ -0,0 +1,38 @@
export interface NoteEvent {
time: number;
duration: number;
pitch: number;
velocity: number;
track: number;
}
export interface StructuralFeatures {
tempo: number;
totalDuration: number;
noteDensity: number[];
registerDistribution: { low: number; mid: number; high: number };
trackRoles: Map<number, Role>;
}
export type Role = 'bass' | 'drone' | 'ostinato' | 'texture' | 'accents';
export interface RoleAssignment {
role: Role;
sourceTrack: number;
events: NoteEvent[];
confidence: number;
}
export interface SynthLayer {
role: Role;
oscillators: OscillatorNode[];
gainNode: GainNode;
filterNode: BiquadFilterNode;
}
export interface MotifConfig {
lookaheadTime: number;
scheduleInterval: number;
fadeTime: number;
maxOscillators: number;
}