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:
+35
@@ -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
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user