From 4b7836b9259caec0610f43ac926bf4693a5ee126 Mon Sep 17 00:00:00 2001 From: b1rdmania <102524336+b1rdmania@users.noreply.github.com> Date: Wed, 17 Dec 2025 20:33:48 +0000 Subject: [PATCH] Initial Motif project setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 35 +++++ index.html | 79 ++++++++++ package.json | 32 ++++ src/core/MotifEngine.ts | 116 ++++++++++++++ src/core/RoleMapper.ts | 71 +++++++++ src/main.ts | 78 ++++++++++ src/midi/MIDIProcessor.ts | 82 ++++++++++ src/synthesis/SynthesisEngine.ts | 252 +++++++++++++++++++++++++++++++ src/types/index.ts | 38 +++++ tsconfig.json | 19 +++ vite.config.ts | 11 ++ 11 files changed, 813 insertions(+) create mode 100644 .gitignore create mode 100644 index.html create mode 100644 package.json create mode 100644 src/core/MotifEngine.ts create mode 100644 src/core/RoleMapper.ts create mode 100644 src/main.ts create mode 100644 src/midi/MIDIProcessor.ts create mode 100644 src/synthesis/SynthesisEngine.ts create mode 100644 src/types/index.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c85dcb --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist/ +build/ + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log + +# Runtime +.cache/ +.tmp/ \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..5ea9b74 --- /dev/null +++ b/index.html @@ -0,0 +1,79 @@ + + + + + + Motif - Procedural Music Synthesis + + + +
+

MOTIF

+

Procedural Music Synthesis from MIDI Structure

+ +
+ + + + +
+ +
Ready. Enter a song name to begin synthesis.
+
+ + + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..254856a --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "motif", + "version": "0.1.0", + "description": "Procedural Music Synthesis from MIDI Structure", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint src --ext .ts,.tsx", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "eslint": "^8.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.0" + }, + "dependencies": { + "midi-parser-js": "^4.0.4" + }, + "keywords": [ + "web-audio", + "procedural-synthesis", + "midi", + "music-generation" + ], + "author": "", + "license": "MIT" +} \ No newline at end of file diff --git a/src/core/MotifEngine.ts b/src/core/MotifEngine.ts new file mode 100644 index 0000000..d999c1f --- /dev/null +++ b/src/core/MotifEngine.ts @@ -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 { + // 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 { + 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); + } +} \ No newline at end of file diff --git a/src/core/RoleMapper.ts b/src/core/RoleMapper.ts new file mode 100644 index 0000000..10b36db --- /dev/null +++ b/src/core/RoleMapper.ts @@ -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 { + const tracks = new Map(); + + 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; + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..a79a390 --- /dev/null +++ b/src/main.ts @@ -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 { + 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 { + 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(); \ No newline at end of file diff --git a/src/midi/MIDIProcessor.ts b/src/midi/MIDIProcessor.ts new file mode 100644 index 0000000..223c829 --- /dev/null +++ b/src/midi/MIDIProcessor.ts @@ -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() + }; + } +} \ No newline at end of file diff --git a/src/synthesis/SynthesisEngine.ts b/src/synthesis/SynthesisEngine.ts new file mode 100644 index 0000000..d2fa847 --- /dev/null +++ b/src/synthesis/SynthesisEngine.ts @@ -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 = 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(); + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..e93af7a --- /dev/null +++ b/src/types/index.ts @@ -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; +} + +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; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..43bfd60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..0b22f03 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + server: { + port: 3000, + }, + build: { + outDir: 'dist', + sourcemap: true, + }, +}) \ No newline at end of file