243 lines
8.1 KiB
JavaScript
243 lines
8.1 KiB
JavaScript
import { existsSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
BARS,
|
|
CHORD_ROOTS,
|
|
eventsAtStep,
|
|
midiToFrequency,
|
|
STEPS_PER_BAR,
|
|
TOTAL_STEPS,
|
|
} from '../../app/static/js/logic/marchPattern.js';
|
|
import { allSoundFiles, AUDIO_PATH, SOUND_FILES, SOUND_OPTIONS } from '../../app/static/js/systems/AudioAssets.js';
|
|
import { AudioManager } from '../../app/static/js/systems/AudioManager.js';
|
|
import { SafeStorage, Settings } from '../../app/static/js/systems/Settings.js';
|
|
import { SYNTH_RECIPES } from '../../app/static/js/systems/Synth.js';
|
|
|
|
const ASSET_DIR = join(import.meta.dirname, '..', '..', 'app', 'static', 'assets', AUDIO_PATH);
|
|
|
|
// ------------------------------------------------------------ minimaler AudioContext-Ersatz
|
|
|
|
class FakeParam {
|
|
constructor(value = 1) {
|
|
this.value = value;
|
|
}
|
|
setValueAtTime(v) {
|
|
this.value = v;
|
|
return this;
|
|
}
|
|
exponentialRampToValueAtTime() {
|
|
return this;
|
|
}
|
|
setTargetAtTime(v) {
|
|
this.value = v;
|
|
return this;
|
|
}
|
|
}
|
|
|
|
class FakeNode {
|
|
constructor(ctx) {
|
|
this.ctx = ctx;
|
|
}
|
|
connect(node) {
|
|
return node;
|
|
}
|
|
disconnect() {}
|
|
}
|
|
|
|
class FakeContext {
|
|
constructor() {
|
|
this.state = 'running';
|
|
this.currentTime = 0;
|
|
this.sampleRate = 8000;
|
|
this.destination = new FakeNode(this);
|
|
this.created = { oscillator: 0, source: 0 };
|
|
this.startedBuffers = [];
|
|
}
|
|
createGain() {
|
|
return Object.assign(new FakeNode(this), { gain: new FakeParam(1) });
|
|
}
|
|
createOscillator() {
|
|
this.created.oscillator += 1;
|
|
return Object.assign(new FakeNode(this), { frequency: new FakeParam(440), type: 'sine', start() {}, stop() {} });
|
|
}
|
|
createBufferSource() {
|
|
this.created.source += 1;
|
|
const ctx = this;
|
|
return Object.assign(new FakeNode(this), {
|
|
playbackRate: new FakeParam(1),
|
|
buffer: null,
|
|
start() {
|
|
ctx.startedBuffers.push(this.buffer);
|
|
},
|
|
stop() {},
|
|
});
|
|
}
|
|
createBiquadFilter() {
|
|
return Object.assign(new FakeNode(this), { frequency: new FakeParam(350), Q: new FakeParam(1), type: 'lowpass' });
|
|
}
|
|
createBuffer(_channels, length) {
|
|
const data = new Float32Array(length);
|
|
return { getChannelData: () => data };
|
|
}
|
|
decodeAudioData(bytes) {
|
|
return Promise.resolve({ decodedFrom: bytes.byteLength });
|
|
}
|
|
resume() {
|
|
this.state = 'running';
|
|
return Promise.resolve();
|
|
}
|
|
}
|
|
|
|
function memorySettings() {
|
|
const data = new Map();
|
|
return new Settings(new SafeStorage({ getItem: (k) => data.get(k) ?? null, setItem: (k, v) => data.set(k, v) }));
|
|
}
|
|
|
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
// ------------------------------------------------------------------------------ Tests
|
|
|
|
describe('AudioAssets', () => {
|
|
it('verweist nur auf vorhandene Dateien', () => {
|
|
const missing = allSoundFiles().filter((file) => !existsSync(join(ASSET_DIR, file)));
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
it('hat für jeden Effekt einen synthetischen Ersatz (Spiel bleibt ohne Dateien hörbar)', () => {
|
|
const withoutRecipe = Object.keys(SOUND_FILES).filter((name) => typeof SYNTH_RECIPES[name] !== 'function');
|
|
expect(withoutRecipe).toEqual([]);
|
|
});
|
|
|
|
it('hat für jeden Effekt Abspieloptionen mit gültiger Lautstärke', () => {
|
|
for (const name of Object.keys(SOUND_FILES)) {
|
|
const volume = SOUND_OPTIONS[name]?.volume ?? 1;
|
|
expect(volume, name).toBeGreaterThan(0);
|
|
expect(volume, name).toBeLessThanOrEqual(1);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('AudioManager', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('AudioContext', FakeContext);
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('bleibt vor der ersten Nutzeraktion stumm, ohne Fehler', () => {
|
|
const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() });
|
|
expect(() => audio.play('shot-carbine')).not.toThrow();
|
|
expect(audio.context).toBeNull();
|
|
});
|
|
|
|
it('lädt Dateien vorab und spielt sie nach dem Entsperren ab', async () => {
|
|
const fetchImpl = vi.fn(async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(16) }));
|
|
const audio = new AudioManager(memorySettings(), { staticBase: '/glory/static/', fetchImpl });
|
|
audio.preload();
|
|
expect(fetchImpl).toHaveBeenCalledWith(`/glory/static/assets/${AUDIO_PATH}laserSmall_000.ogg`);
|
|
expect(fetchImpl).toHaveBeenCalledTimes(allSoundFiles().length);
|
|
|
|
audio.unlock();
|
|
await flush();
|
|
await flush();
|
|
audio.play('shot-carbine');
|
|
expect(audio.context.startedBuffers).toHaveLength(1);
|
|
expect(audio.context.startedBuffers[0]).toEqual({ decodedFrom: 16 });
|
|
});
|
|
|
|
it('weicht auf den Synthesizer aus, wenn Dateien fehlen', async () => {
|
|
const fetchImpl = vi.fn(async () => ({ ok: false }));
|
|
const audio = new AudioManager(memorySettings(), { fetchImpl });
|
|
audio.preload();
|
|
audio.unlock();
|
|
await flush();
|
|
audio.play('explosion-big');
|
|
// Gespielt wurde nur Rauschen aus dem Synthesizer, keine dekodierte Datei.
|
|
expect(audio.context.startedBuffers.filter((b) => b && 'decodedFrom' in b)).toHaveLength(0);
|
|
expect(audio.context.created.oscillator).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('begrenzt schnelle Wiederholungen desselben Effekts', () => {
|
|
const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() });
|
|
audio.unlock();
|
|
const before = audio.context.created.oscillator;
|
|
audio.play('combo-step');
|
|
audio.play('combo-step');
|
|
expect(audio.context.created.oscillator - before).toBe(1);
|
|
});
|
|
|
|
it('überträgt Lautstärkeänderungen sofort auf die Kanäle', () => {
|
|
const settings = memorySettings();
|
|
const audio = new AudioManager(settings, { fetchImpl: vi.fn() });
|
|
audio.unlock();
|
|
settings.setVolume('music', 0.2);
|
|
settings.setVolume('master', 0.6);
|
|
expect(audio.gains.music.gain.value).toBe(0.2);
|
|
expect(audio.gains.master.gain.value).toBe(0.6);
|
|
});
|
|
|
|
it('startet vorgemerkte Musik erst nach dem Entsperren und stoppt sie wieder', () => {
|
|
vi.useFakeTimers();
|
|
const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() });
|
|
audio.startMusic();
|
|
expect(audio.music).toBeNull();
|
|
audio.unlock();
|
|
expect(audio.music).not.toBeNull();
|
|
vi.advanceTimersByTime(200);
|
|
expect(audio.context.created.oscillator).toBeGreaterThan(0);
|
|
audio.stopMusic();
|
|
expect(audio.music).toBeNull();
|
|
expect(audio.musicWanted).toBe(false);
|
|
});
|
|
|
|
it('senkt die Musik in der Pause ab', () => {
|
|
const audio = new AudioManager(memorySettings(), { fetchImpl: vi.fn() });
|
|
audio.unlock();
|
|
audio.duck(true);
|
|
expect(audio.gains.duck.gain.value).toBeLessThan(1);
|
|
audio.duck(false);
|
|
expect(audio.gains.duck.gain.value).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Marschmuster', () => {
|
|
const all = Array.from({ length: TOTAL_STEPS }, (_, step) => eventsAtStep(step));
|
|
const count = (instrument) => all.flat().filter((e) => e.instrument === instrument).length;
|
|
|
|
it('hat 8 Takte à 16 Schritte', () => {
|
|
expect(TOTAL_STEPS).toBe(BARS * STEPS_PER_BAR);
|
|
expect(CHORD_ROOTS).toHaveLength(BARS);
|
|
});
|
|
|
|
it('schlägt die große Trommel auf 1 und 3 jedes Takts', () => {
|
|
expect(count('kick')).toBe(BARS * 2);
|
|
for (let bar = 0; bar < BARS; bar += 1) {
|
|
expect(all[bar * STEPS_PER_BAR].some((e) => e.instrument === 'kick')).toBe(true);
|
|
expect(all[bar * STEPS_PER_BAR + 8].some((e) => e.instrument === 'kick')).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('spielt die Melodie nur mit Tönen aus C-Dur', () => {
|
|
const cMajor = new Set([0, 2, 4, 5, 7, 9, 11]);
|
|
const melody = all.flat().filter((e) => e.instrument === 'lead');
|
|
expect(melody.length).toBeGreaterThan(20);
|
|
for (const event of melody) expect(cMajor.has(event.notes[0] % 12)).toBe(true);
|
|
});
|
|
|
|
it('wiederholt sich nahtlos', () => {
|
|
expect(eventsAtStep(TOTAL_STEPS)).toEqual(eventsAtStep(0));
|
|
expect(eventsAtStep(-1)).toEqual(eventsAtStep(TOTAL_STEPS - 1));
|
|
});
|
|
|
|
it('rechnet MIDI-Töne in Frequenzen um', () => {
|
|
expect(midiToFrequency(69)).toBeCloseTo(440);
|
|
expect(midiToFrequency(57)).toBeCloseTo(220);
|
|
expect(midiToFrequency(60)).toBeCloseTo(261.63, 1);
|
|
});
|
|
});
|