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
+35
View File
@@ -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/
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Motif - Procedural Music Synthesis</title>
<style>
body {
font-family: 'Monaco', 'Consolas', monospace;
background: #0a0a0a;
color: #e0e0e0;
margin: 0;
padding: 20px;
line-height: 1.5;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
color: #00ff88;
text-align: center;
margin-bottom: 30px;
}
.controls {
margin: 20px 0;
text-align: center;
}
button {
background: #333;
color: #e0e0e0;
border: 1px solid #555;
padding: 10px 20px;
margin: 0 10px;
cursor: pointer;
font-family: inherit;
}
button:hover {
background: #444;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#status {
margin: 20px 0;
padding: 10px;
background: #1a1a1a;
border-left: 3px solid #00ff88;
min-height: 20px;
}
input {
background: #333;
color: #e0e0e0;
border: 1px solid #555;
padding: 8px 12px;
margin: 0 10px;
font-family: inherit;
}
</style>
</head>
<body>
<div class="container">
<h1>MOTIF</h1>
<p>Procedural Music Synthesis from MIDI Structure</p>
<div class="controls">
<input type="text" id="songInput" placeholder="Enter song name..." />
<button id="generateBtn">Generate</button>
<button id="playBtn" disabled>Play</button>
<button id="stopBtn" disabled>Stop</button>
</div>
<div id="status">Ready. Enter a song name to begin synthesis.</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+32
View File
@@ -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"
}
+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;
}
+19
View File
@@ -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"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
export default defineConfig({
server: {
port: 3000,
},
build: {
outDir: 'dist',
sourcemap: true,
},
})