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