Files
motif/src/synthesis/SynthesisEngine.ts
T
b1rdmania 34092f2301 Fix 10-second silence at start of Motif playback
Normalize all event times to start at 0 by finding the earliest
event across all roles and subtracting that offset. Prevents
silence when MIDI files have delayed first notes.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 21:20:28 +00:00

470 lines
14 KiB
TypeScript

import type { RoleAssignment, MotifConfig, SynthLayer, Role, NoteEvent, ChordEvent } from '../types';
export class SynthesisEngine {
private audioContext: AudioContext;
private config: MotifConfig;
private masterGain: GainNode;
private layers: Map<Role, SynthLayer> = new Map();
private roleAssignments: Map<Role, RoleAssignment> = new Map();
private isPlaying = false;
private schedulerIntervalId: number | null = null;
private startTime = 0;
private nextEventIndex = new Map<Role, number>();
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();
// Find the earliest event time across all assignments
let earliestTime = Infinity;
for (const assignment of assignments) {
if (assignment.events.length > 0) {
earliestTime = Math.min(earliestTime, assignment.events[0].time);
}
if (assignment.chords.length > 0) {
earliestTime = Math.min(earliestTime, assignment.chords[0].time);
}
}
// If we found events, normalize times to start at 0
if (earliestTime !== Infinity && earliestTime > 0) {
console.log('Normalizing event times, earliest was:', earliestTime);
for (const assignment of assignments) {
// Normalize note events
for (const event of assignment.events) {
event.time -= earliestTime;
}
// Normalize chord events
for (const chord of assignment.chords) {
chord.time -= earliestTime;
}
}
}
// Store role assignments and create layers
for (const assignment of assignments) {
const layer = this.createSynthLayer(assignment.role);
this.layers.set(assignment.role, layer);
this.roleAssignments.set(assignment.role, assignment);
this.nextEventIndex.set(assignment.role, 0);
}
console.log('Setup layers for roles:', Array.from(this.roleAssignments.keys()));
}
start(): void {
if (this.isPlaying) return;
this.isPlaying = true;
this.startTime = this.audioContext.currentTime;
// Reset event indices
for (const role of this.roleAssignments.keys()) {
this.nextEventIndex.set(role, 0);
}
// Start scheduler to play actual MIDI events
this.schedulerIntervalId = window.setInterval(() => {
this.scheduleEvents();
}, this.config.scheduleInterval);
console.log('Started synthesis with', this.roleAssignments.size, 'roles');
}
stop(): void {
if (!this.isPlaying) return;
this.isPlaying = false;
if (this.schedulerIntervalId) {
clearInterval(this.schedulerIntervalId);
this.schedulerIntervalId = null;
}
// Fade out all layers
this.fadeOutAllLayers();
}
setVolume(volume: number): void {
this.masterGain.gain.value = Math.max(0, Math.min(1, volume));
}
seek(progress: number): void {
if (!this.isPlaying) return;
// Calculate the new start time based on progress
const duration = this.getDuration();
const targetTime = progress * duration;
// Adjust startTime to effectively seek to the target position
this.startTime = this.audioContext.currentTime - targetTime;
// Reset event indices to the appropriate position
for (const [role, assignment] of this.roleAssignments) {
const events = assignment.events;
if (events.length > 0) {
// Find the first event after the target time
let index = 0;
while (index < events.length && events[index].time < targetTime) {
index++;
}
this.nextEventIndex.set(role, index);
}
}
}
getProgress(): number {
if (!this.isPlaying) return 0;
const duration = this.getDuration();
if (duration === 0) return 0;
const currentTime = this.audioContext.currentTime - this.startTime;
return Math.max(0, Math.min(1, currentTime / duration));
}
getCurrentTime(): number {
if (!this.isPlaying) return 0;
return Math.max(0, this.audioContext.currentTime - this.startTime);
}
getDuration(): number {
let maxDuration = 0;
for (const assignment of this.roleAssignments.values()) {
if (assignment.events.length > 0) {
const lastEvent = assignment.events[assignment.events.length - 1];
const eventEnd = lastEvent.time + lastEvent.duration;
maxDuration = Math.max(maxDuration, eventEnd);
}
if (assignment.chords.length > 0) {
const lastChord = assignment.chords[assignment.chords.length - 1];
const chordEnd = lastChord.time + lastChord.duration;
maxDuration = Math.max(maxDuration, chordEnd);
}
}
return maxDuration;
}
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;
case 'melody':
// Passthrough mode - balanced sound for all notes
gain.gain.value = 0.35;
filter.type = 'lowpass';
filter.frequency.value = 4000; // Brighter sound for full range
break;
}
}
private midiToFrequency(midiNote: number): number {
return 440 * Math.pow(2, (midiNote - 69) / 12);
}
private scheduleNote(role: Role, pitch: number, duration: number, velocity: number, when: number): void {
const layer = this.layers.get(role);
if (!layer) return;
const osc = this.audioContext.createOscillator();
const envelope = this.audioContext.createGain();
// Convert MIDI pitch to frequency
const frequency = this.midiToFrequency(pitch);
osc.frequency.value = frequency;
// Choose oscillator type based on role
switch (role) {
case 'bass':
osc.type = 'square';
break;
case 'drone':
osc.type = 'sawtooth';
break;
case 'ostinato':
osc.type = 'triangle';
break;
case 'melody':
osc.type = 'triangle';
break;
case 'texture':
case 'accents':
osc.type = 'sine';
break;
}
osc.connect(envelope);
envelope.connect(layer.filterNode);
// Envelope based on velocity and duration with minimum times to prevent clicks
const gainValue = velocity * 0.5; // Scale velocity
const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack
const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
envelope.gain.setValueAtTime(gainValue, when + Math.max(attackTime, duration - releaseTime));
envelope.gain.exponentialRampToValueAtTime(0.001, when + duration + releaseTime);
osc.start(when);
osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
// Clean up after note ends
setTimeout(() => {
try {
osc.disconnect();
envelope.disconnect();
} catch (e) {
// Already disconnected
}
}, (duration + releaseTime + 0.1) * 1000);
}
private scheduleEvents(): void {
if (!this.isPlaying) return;
const currentTime = this.audioContext.currentTime;
const scheduleUntil = currentTime + this.config.lookaheadTime;
// Schedule events for each role
for (const [role, assignment] of this.roleAssignments) {
this.scheduleRoleEvents(role, assignment, scheduleUntil);
}
}
private scheduleRoleEvents(role: Role, assignment: RoleAssignment, scheduleUntil: number): void {
const events = assignment.events;
const chords = assignment.chords;
if (!events.length && !chords.length) return;
// For roles that support polyphony (drone, texture), prefer chords
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
this.scheduleChordEvents(role, chords, scheduleUntil);
} else {
this.scheduleSingleEvents(role, events, scheduleUntil);
}
}
private scheduleChordEvents(role: Role, chords: ChordEvent[], scheduleUntil: number): void {
let chordIndex = this.nextEventIndex.get(role) || 0;
while (chordIndex < chords.length) {
const chord = chords[chordIndex];
const eventTime = this.startTime + chord.time;
// Stop if we're past the lookahead window
if (eventTime > scheduleUntil) break;
// Schedule if the chord hasn't been played yet
if (eventTime >= this.audioContext.currentTime) {
this.scheduleChord(
role,
chord.pitches,
Math.max(0.05, chord.duration),
chord.velocity,
eventTime
);
}
chordIndex++;
}
// Update the next event index
this.nextEventIndex.set(role, chordIndex);
// Loop if we've reached the end
if (chordIndex >= chords.length) {
this.nextEventIndex.set(role, 0);
// Reset start time for looping
if (Array.from(this.nextEventIndex.values()).every(idx => idx === 0)) {
this.startTime = this.audioContext.currentTime;
}
}
}
private scheduleSingleEvents(role: Role, events: NoteEvent[], scheduleUntil: number): void {
let eventIndex = this.nextEventIndex.get(role) || 0;
while (eventIndex < events.length) {
const event = events[eventIndex];
const eventTime = this.startTime + event.time;
// Stop if we're past the lookahead window
if (eventTime > scheduleUntil) break;
// Schedule if the event hasn't been played yet
if (eventTime >= this.audioContext.currentTime) {
this.scheduleNote(
role,
event.pitch,
Math.max(0.05, event.duration), // Minimum duration
event.velocity,
eventTime
);
}
eventIndex++;
}
// Update the next event index
this.nextEventIndex.set(role, eventIndex);
// Loop if we've reached the end
if (eventIndex >= events.length) {
this.nextEventIndex.set(role, 0);
// Reset start time for looping
if (Array.from(this.nextEventIndex.values()).every(idx => idx === 0)) {
this.startTime = this.audioContext.currentTime;
}
}
}
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 scheduleChord(role: Role, pitches: number[], duration: number, velocity: number, when: number): void {
const layer = this.layers.get(role);
if (!layer) return;
// Create oscillator for each pitch in the chord
for (const pitch of pitches) {
const osc = this.audioContext.createOscillator();
const envelope = this.audioContext.createGain();
// Convert MIDI pitch to frequency
const frequency = this.midiToFrequency(pitch);
osc.frequency.value = frequency;
// Choose oscillator type based on role
switch (role) {
case 'bass':
osc.type = 'square';
break;
case 'drone':
osc.type = 'sawtooth';
break;
case 'ostinato':
osc.type = 'triangle';
break;
case 'texture':
osc.type = 'sine';
break;
case 'melody':
osc.type = 'triangle';
break;
case 'accents':
osc.type = 'sine';
break;
}
osc.connect(envelope);
envelope.connect(layer.filterNode);
// Envelope based on velocity and duration, scaled for chords with minimum times to prevent clicks
const gainValue = (velocity * 0.3) / Math.max(pitches.length * 0.5, 1); // Scale down for chords
const attackTime = Math.max(0.005, Math.min(0.05, duration * 0.1)); // Min 5ms attack
const releaseTime = Math.max(0.01, Math.min(0.1, duration * 0.3)); // Min 10ms release
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
envelope.gain.setValueAtTime(gainValue, when + Math.max(attackTime, duration - releaseTime));
envelope.gain.exponentialRampToValueAtTime(0.001, when + duration + releaseTime);
osc.start(when);
osc.stop(when + duration + releaseTime + 0.01); // Stop after envelope completes
// Clean up after note ends
setTimeout(() => {
try {
osc.disconnect();
envelope.disconnect();
} catch (e) {
// Already disconnected
}
}, (duration + releaseTime + 0.1) * 1000);
}
}
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();
}
}