Files
2026-09-24 17:57:54 +02:00

131 lines
4.3 KiB
JavaScript

/**
* 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);
}
}