/** * Spielt den Marsch aus `logic/marchPattern.js` mit synthetischen Instrumenten. * * Zeitplanung nach dem Muster „Vorausplanen mit zwei Uhren“: Ein grober JavaScript- * Timer plant alle 25 ms die Noten der nächsten ~120 ms exakt auf der Audio-Uhr ein. * Direkt im Timer zu spielen würde hörbar wackeln, weil JavaScript-Timer ungenau sind. * @module systems/Music */ import { eventsAtStep, midiToFrequency, STEP_SECONDS, TOTAL_STEPS } from '../logic/marchPattern.js'; const SCHEDULER_INTERVAL_MS = 25; const LOOKAHEAD_SECONDS = 0.12; /** Grundlautstärke der Musik vor dem Musik-Regler. */ const MUSIC_LEVEL = 0.55; /** Liegt die Planung weiter zurück (Tab im Hintergrund), wird neu synchronisiert statt nachgeholt. */ const MAX_LAG_SECONDS = 0.25; export class MarchMusic { /** * @param {AudioContext} ctx * @param {AudioNode} destination Musik-Bus. * @param {AudioBuffer} noiseBuffer */ constructor(ctx, destination, noiseBuffer) { this.ctx = ctx; this.noiseBuffer = noiseBuffer; this.out = ctx.createGain(); this.out.gain.value = MUSIC_LEVEL; this.out.connect(destination); this.timer = null; this.step = 0; this.nextTime = 0; } start() { if (this.timer !== null) return; this.step = 0; this.nextTime = this.ctx.currentTime + 0.1; this.timer = setInterval(() => this.schedule(), SCHEDULER_INTERVAL_MS); } /** Blendet aus und gibt die Knoten frei. */ stop() { if (this.timer !== null) clearInterval(this.timer); this.timer = null; const now = this.ctx.currentTime; this.out.gain.setTargetAtTime(0, now, 0.08); const out = this.out; setTimeout(() => out.disconnect(), 600); } /** @private */ schedule() { const now = this.ctx.currentTime; if (this.nextTime < now - MAX_LAG_SECONDS) this.nextTime = now + 0.05; while (this.nextTime < now + LOOKAHEAD_SECONDS) { for (const event of eventsAtStep(this.step)) this.play(event, this.nextTime); this.nextTime += STEP_SECONDS; this.step = (this.step + 1) % TOTAL_STEPS; } } /** * @param {import('../logic/marchPattern.js').MusicEvent} event * @param {number} time Audio-Zeit. * @private */ play(event, time) { const length = event.lengthSteps * STEP_SECONDS; switch (event.instrument) { case 'kick': this.voice('sine', 150, time, 0.22, 0.9 * event.velocity, 45); break; case 'snare': this.snare(time, 0.35 * event.velocity); break; case 'bass': this.voice('triangle', midiToFrequency(event.notes[0]), time, length * 0.9, 0.35 * event.velocity); break; case 'stab': for (const note of event.notes) { this.voice('sawtooth', midiToFrequency(note), time, length * 0.7, 0.05 * event.velocity, undefined, 1400); } break; case 'lead': this.voice('square', midiToFrequency(event.notes[0]), time, length * 0.92, 0.06 * event.velocity, undefined, 2600); break; default: break; } } /** @private */ voice(type, freq, time, duration, volume, freqEnd, lowpass) { const osc = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, time); if (freqEnd) osc.frequency.exponentialRampToValueAtTime(freqEnd, time + duration); gain.gain.setValueAtTime(0.0001, time); gain.gain.exponentialRampToValueAtTime(volume, time + 0.01); gain.gain.exponentialRampToValueAtTime(0.0001, time + duration); let node = osc.connect(gain); if (lowpass) { const filter = this.ctx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.value = lowpass; node = gain.connect(filter); } node.connect(this.out); osc.start(time); osc.stop(time + duration + 0.05); } /** @private */ snare(time, volume) { const source = this.ctx.createBufferSource(); source.buffer = this.noiseBuffer; const filter = this.ctx.createBiquadFilter(); filter.type = 'highpass'; filter.frequency.value = 1500; const gain = this.ctx.createGain(); gain.gain.setValueAtTime(volume, time); gain.gain.exponentialRampToValueAtTime(0.0001, time + 0.12); source.connect(filter).connect(gain).connect(this.out); source.start(time, Math.random() * 0.5); source.stop(time + 0.15); } }