Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/07-ASSET-MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ Frame lists approved as they are (A14).
| Hidden Garden (prologue) | 1 chunk `hg-0-0` ([art/stages/hidden-garden](../art/stages/hidden-garden/README.md)): bench, path, jetty, the wrong reflection; layers 03/04/05/07. A second chunk waits for seam repair | 🟡 in review · ⬜ collision |
| Jenny's arena | right chunk of Rusty Creek (boss clearing) | ✅ art · ⬜ collision |

## Audio (to source during the jam)
## Audio (synthesized in code)

Footsteps (girder, grass), chair whoosh + hit, rover motor, crab servos, drone hum, Jenny engine + crash, fluid absorb, crash-dialog "ding", one ambient loop per region, one boss loop. Use CC0 sources only and record every source in `NOTICE.md`.
Footsteps (girder, grass), chair whoosh + hit, rover motor, crab servos, drone hum, Jenny engine + crash, fluid absorb, crash-dialog "ding", one ambient loop per region, one boss loop.

Every sound is **synthesized at runtime with Web Audio** – no audio files, so nothing to license and nothing that breaks decision 14 (no audio in Git). Recipes are plain data in `game/src/fx/sound/recipes.ts` (effects) and `loops.ts` (Hidden Garden, Rusty Creek, Jenny); the `Audio` scene plays them. Game code calls `sound.play('chair-whoosh')` / `sound.loop('boss-jenny')`; hits, defeats, the boss and region turns are wired to the game events, and the anomaly state plays the region loop slightly flat. M mutes.

<!-- nav:start -->
[← 06 – Visual direction](06-VISUAL-DIRECTION.md) · [Design docs](README.md) · **7 / 14** · [08 – Tech stack and where things live →](08-TECH-STACK.md)
Expand Down
123 changes: 123 additions & 0 deletions game/src/fx/sound/Audio.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import Phaser from 'phaser';
import { gameEvents } from '../../core/events';
import { LOOPS, LoopId, LoopState, loopVoices, regionLoop } from './loops';
import { RECIPES, SoundId } from './recipes';
import { noiseBuffer, playVoices } from './synth';

const MASTER = 0.6;
/** Bars are scheduled this far ahead of the audio clock, so a slow frame never gaps the loop. */
const LOOKAHEAD = 0.5;

let active: Audio | undefined;

/**
* Game code plays effects through this, e.g. `sound.play('chair-whoosh')` (lane B: footsteps, swings, motors).
* Calls before the first input – when browsers keep audio locked – are silently dropped.
*/
export const sound = {
play(id: SoundId): void { active?.play(id); },
loop(id: LoopId | null): void { active?.setLoop(id); },
/** For debugging: whether audio is unlocked, which loop plays and how many bars it has scheduled. */
state(): { context: AudioContextState | 'none'; loop: LoopId | null; bars: number } {
return { context: active?.ctx?.state ?? 'none', loop: active?.loopId ?? null, bars: active?.bar ?? 0 };
},
};

/**
* The sound of the slice (#71): synthesized effects and loops, no audio files. Listens to the game events
* for hits, defeats, the boss and region turns; M mutes.
*/
export class Audio extends Phaser.Scene {
ctx?: AudioContext;
private master?: GainNode;
private loopBus?: GainNode;
private bed?: { source: AudioBufferSourceNode; gain: GainNode };
loopId: LoopId | null = null;
private loopState: LoopState = 'clean';
bar = 0;
private nextBarAt = 0;

constructor() { super({ key: 'Audio', active: true }); }

create(): void {
const manager = this.sound as Phaser.Sound.WebAudioSoundManager;
if (!('context' in manager)) return; // no Web Audio: the game stays silent rather than failing
this.ctx = manager.context;
this.master = this.ctx.createGain();
this.master.gain.value = MASTER;
this.master.connect(manager.destination);
this.loopBus = this.ctx.createGain();
this.loopBus.connect(this.master);
active = this;

const offs = [
gameEvents.on('player-hit', () => this.play('player-hurt')),
gameEvents.on('enemy-defeated', () => this.play('enemy-defeated')),
gameEvents.on('boss-defeated', () => { this.play('jenny-crash'); this.setLoop(null); }),
gameEvents.on('region-turned', ({ region, state }) => { this.loopState = state; this.setLoop(regionLoop(region)); }),
];
this.input.keyboard?.on('keydown-M', () => manager.setMute(!manager.mute));
// Dev builds: F6 steps through the loops, F7 through the effects – for listening with headphones (#71).
if (import.meta.env.DEV) {
const effects = Object.keys(RECIPES) as SoundId[];
const loops: (LoopId | null)[] = [...(Object.keys(LOOPS) as LoopId[]), null];
let effect = 0;
let loop = 0;
this.input.keyboard?.on('keydown-F7', () => {
const id = effects[effect++ % effects.length];
console.info(`[sound] ${id}`);
this.play(id);
});
this.input.keyboard?.on('keydown-F6', () => {
const id = loops[loop++ % loops.length];
console.info(`[sound] loop ${id ?? 'off'}`);
this.setLoop(id);
});
}
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
offs.forEach((off) => off());
this.setLoop(null);
if (active === this) active = undefined;
});
}

play(id: SoundId): void {
if (this.ctx && this.master && this.ctx.state === 'running') playVoices(this.ctx, this.master, RECIPES[id]);
}

/** Switches the background loop; null stops it. The same loop keeps playing (only its state may change). */
setLoop(id: LoopId | null): void {
if (id === this.loopId) return;
this.bed?.source.stop();
this.bed = undefined;
this.loopId = id;
this.bar = 0;
this.nextBarAt = 0;
const loop = id && LOOPS[id];
if (!loop?.bed || !this.ctx || !this.loopBus) return;
const source = this.ctx.createBufferSource();
source.buffer = noiseBuffer(this.ctx);
source.loop = true;
const filter = this.ctx.createBiquadFilter();
filter.type = loop.bed.type;
filter.frequency.value = loop.bed.freq;
filter.Q.value = loop.bed.q ?? 1;
const gain = this.ctx.createGain();
gain.gain.value = loop.bed.gain;
source.connect(filter).connect(gain).connect(this.loopBus);
source.start();
this.bed = { source, gain };
}

update(): void {
const ctx = this.ctx;
if (!ctx || !this.loopBus || !this.loopId || ctx.state !== 'running') return;
const loop = LOOPS[this.loopId];
if (this.nextBarAt < ctx.currentTime) this.nextBarAt = ctx.currentTime + 0.05;
while (this.nextBarAt < ctx.currentTime + LOOKAHEAD) {
playVoices(ctx, this.loopBus, loopVoices(this.loopId, this.bar, this.loopState), this.nextBarAt);
this.bar++;
this.nextBarAt += loop.bar;
}
}
}
85 changes: 85 additions & 0 deletions game/src/fx/sound/loops.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { seeded } from '../anomalyLayout';
import { Voice } from './recipes';

/**
* One ambient loop per region and one boss loop (docs/07), as bars of synthesized voices over a steady
* noise bed. Bars are deterministic per index, so a loop sounds the same every time it plays.
*/

export type LoopId = 'hidden-garden' | 'rusty-creek' | 'boss-jenny';
export type LoopState = 'clean' | 'anomaly';

export interface Loop {
/** Seconds per bar. */
bar: number;
/** A filtered noise bed that runs under the whole loop: wind, water. */
bed?: { type: BiquadFilterType; freq: number; q?: number; gain: number };
/** The voices of bar `index`; `start` is seconds into the bar. */
voices(index: number): Voice[];
}

const chirp = (start: number, pitch: number): Voice =>
({ wave: 'sine', from: pitch, to: pitch * 1.6, start, dur: 0.09, gain: 0.05 });

export const LOOPS: Record<LoopId, Loop> = {
// Wind in the garden, a soft pad and a few birds.
'hidden-garden': {
bar: 4,
bed: { type: 'lowpass', freq: 900, gain: 0.05 },
voices(index) {
const rand = seeded(1000 + index);
const birds = Array.from({ length: 2 + Math.floor(rand() * 2) }, () => chirp(rand() * 3.6, 1900 + rand() * 900));
const root = [196, 174.6, 220, 164.8][index % 4];
return [
{ wave: 'sine', from: root, start: 0, dur: 4, attack: 1.2, gain: 0.05 },
{ wave: 'sine', from: root * 1.5, start: 0, dur: 4, attack: 1.6, gain: 0.03 },
...birds,
];
},
},
// Running water, a low hum from the steel, now and then a girder ticking.
'rusty-creek': {
bar: 4,
bed: { type: 'bandpass', freq: 700, q: 0.8, gain: 0.07 },
voices(index) {
const rand = seeded(2000 + index);
const ticks: Voice[] = rand() < 0.5 ? [{
wave: 'noise', start: rand() * 3.5, dur: 0.05, gain: 0.06, filter: { type: 'bandpass', from: 3000, q: 8 },
}] : [];
return [{ wave: 'triangle', from: 65.4, start: 0, dur: 4, attack: 1, gain: 0.08 }, ...ticks];
},
},
// Jenny: 140 bpm, kick on 1 and 3, hats on the off-beats, a saw bass riff.
'boss-jenny': {
bar: (60 / 140) * 4,
voices(index) {
const beat = 60 / 140;
const riff = index % 2 === 0 ? [55, 55, 65.4, 49] : [55, 55, 73.4, 61.7];
const out: Voice[] = [];
for (let b = 0; b < 4; b++) {
if (b % 2 === 0) out.push({ wave: 'sine', from: 120, to: 40, start: b * beat, dur: 0.15, gain: 0.4 });
out.push({ wave: 'noise', start: b * beat + beat / 2, dur: 0.04, gain: 0.07, filter: { type: 'highpass', from: 6000 } });
out.push({ wave: 'sawtooth', from: riff[b], start: b * beat, dur: beat * 0.9, gain: 0.16, filter: { type: 'lowpass', from: 700 } });
}
return out;
},
},
};

/** The anomaly plays the same loop slightly flat – the place sounds wrong without changing tune. */
export const ANOMALY_DETUNE = 0.94;

export function loopVoices(id: LoopId, index: number, state: LoopState = 'clean'): Voice[] {
const voices = LOOPS[id].voices(index);
if (state === 'clean') return voices;
return voices.map((v) => ({
...v,
from: v.from && v.from * ANOMALY_DETUNE,
to: v.to && v.to * ANOMALY_DETUNE,
}));
}

/** Which ambient loop a region plays; regions without their own loop fall back to the creek. */
export function regionLoop(region: string): LoopId {
return region === 'hidden-garden' ? 'hidden-garden' : 'rusty-creek';
}
92 changes: 92 additions & 0 deletions game/src/fx/sound/recipes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Every sound in the slice, synthesized: no audio files (decision 14, docs/07). A recipe is a few voices –
* an oscillator or noise with a pitch glide, an envelope and an optional filter sweep – kept as plain data
* so the list and the loudness are tested without Web Audio.
*/

export type Wave = 'sine' | 'square' | 'sawtooth' | 'triangle' | 'noise';

export interface Voice {
wave: Wave;
/** Pitch glide in Hz (ignored for noise). */
from?: number;
to?: number;
/** Seconds after the sound starts. */
start?: number;
dur: number;
/** Attack in seconds; the rest of `dur` decays to silence. */
attack?: number;
/** Peak gain, 0–1. */
gain: number;
filter?: { type: BiquadFilterType; from: number; to?: number; q?: number };
}

export type SoundId =
| 'footstep-girder' | 'footstep-grass'
| 'chair-whoosh' | 'chair-hit'
| 'rover-motor' | 'crab-servo' | 'drone-hum'
| 'jenny-engine' | 'jenny-crash'
| 'fluid-absorb' | 'crash-ding'
| 'player-hurt' | 'enemy-defeated';

export const RECIPES: Record<SoundId, Voice[]> = {
'footstep-girder': [
{ wave: 'noise', dur: 0.06, gain: 0.45, filter: { type: 'bandpass', from: 2600, q: 3 } },
{ wave: 'square', from: 180, to: 120, dur: 0.04, gain: 0.08 },
],
'footstep-grass': [
{ wave: 'noise', dur: 0.09, attack: 0.01, gain: 0.16, filter: { type: 'highpass', from: 3200, to: 1600 } },
],
'chair-whoosh': [
{ wave: 'noise', dur: 0.22, attack: 0.08, gain: 0.7, filter: { type: 'bandpass', from: 600, to: 2400, q: 1 } },
],
'chair-hit': [
{ wave: 'square', from: 220, to: 60, dur: 0.12, gain: 0.3 },
{ wave: 'noise', dur: 0.08, gain: 0.3, filter: { type: 'lowpass', from: 1400, to: 400 } },
],
'rover-motor': [
{ wave: 'sawtooth', from: 70, to: 92, dur: 0.5, attack: 0.05, gain: 0.18, filter: { type: 'lowpass', from: 420 } },
],
'crab-servo': [
{ wave: 'square', from: 420, to: 900, dur: 0.12, gain: 0.3, filter: { type: 'bandpass', from: 1200, q: 1.5 } },
{ wave: 'square', from: 900, to: 520, start: 0.13, dur: 0.1, gain: 0.3, filter: { type: 'bandpass', from: 1200, q: 1.5 } },
],
'drone-hum': [
{ wave: 'sine', from: 220, dur: 0.8, attack: 0.15, gain: 0.12 },
{ wave: 'sine', from: 223, dur: 0.8, attack: 0.15, gain: 0.12 },
],
'jenny-engine': [
{ wave: 'sawtooth', from: 55, to: 140, dur: 0.9, attack: 0.05, gain: 0.3, filter: { type: 'lowpass', from: 300, to: 1200 } },
],
'jenny-crash': [
{ wave: 'noise', dur: 1.2, gain: 0.5, filter: { type: 'lowpass', from: 3000, to: 200 } },
{ wave: 'square', from: 90, to: 30, dur: 0.4, gain: 0.3 },
],
'fluid-absorb': [
{ wave: 'sine', from: 320, to: 80, dur: 0.6, attack: 0.05, gain: 0.3 },
{ wave: 'noise', dur: 0.5, attack: 0.1, gain: 0.12, filter: { type: 'lowpass', from: 500, to: 200 } },
],
// The old desktop's error chime: two soft bells a beat apart.
'crash-ding': [
{ wave: 'sine', from: 880, dur: 0.9, gain: 0.22 },
{ wave: 'sine', from: 1318.5, start: 0.07, dur: 0.8, gain: 0.18 },
{ wave: 'triangle', from: 1760, dur: 0.25, gain: 0.05 },
],
'player-hurt': [
{ wave: 'square', from: 300, to: 120, dur: 0.15, gain: 0.25 },
],
'enemy-defeated': [
{ wave: 'square', from: 600, to: 150, dur: 0.18, gain: 0.2 },
{ wave: 'noise', dur: 0.1, gain: 0.18, filter: { type: 'lowpass', from: 2000 } },
],
};

/** The loudest a recipe can get: every voice's peak at once. Kept under 0.9 so nothing clips. */
export function peakGain(voices: Voice[]): number {
return voices.reduce((sum, v) => sum + v.gain, 0);
}

/** How long a recipe rings, in seconds. */
export function length(voices: Voice[]): number {
return Math.max(...voices.map((v) => (v.start ?? 0) + v.dur));
}
58 changes: 58 additions & 0 deletions game/src/fx/sound/sound.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import manifest from '../../../../docs/07-ASSET-MANIFEST.md?raw';
import { LOOPS, loopVoices, regionLoop, ANOMALY_DETUNE } from './loops';
import { length, peakGain, RECIPES, SoundId } from './recipes';

/** docs/07's audio list, mapped to the recipes that cover it. */
const MANIFEST: [string, SoundId[]][] = [
['Footsteps (girder, grass)', ['footstep-girder', 'footstep-grass']],
['chair whoosh + hit', ['chair-whoosh', 'chair-hit']],
['rover motor', ['rover-motor']],
['crab servos', ['crab-servo']],
['drone hum', ['drone-hum']],
['Jenny engine + crash', ['jenny-engine', 'jenny-crash']],
['fluid absorb', ['fluid-absorb']],
['ding', ['crash-ding']],
];

describe('synthesized sound', () => {
it('covers every effect docs/07 lists', () => {
for (const [phrase, ids] of MANIFEST) {
expect(manifest).toContain(phrase);
for (const id of ids) expect(RECIPES[id]).toBeDefined();
}
});

it('keeps every effect short and under the clipping limit', () => {
for (const [id, voices] of Object.entries(RECIPES)) {
expect(peakGain(voices), id).toBeLessThanOrEqual(0.9);
expect(length(voices), id).toBeLessThanOrEqual(1.5);
}
});

it('keeps every bar of every loop inside its bar and under the limit', () => {
for (const [id, loop] of Object.entries(LOOPS)) {
for (let bar = 0; bar < 8; bar++) {
const voices = loop.voices(bar);
for (const v of voices) expect((v.start ?? 0) + v.dur, id).toBeLessThanOrEqual(loop.bar + 1e-9);
// Voices that overlap in time: at worst all of them at once, plus the bed.
expect(peakGain(voices.filter((v) => (v.start ?? 0) === 0)) + (loop.bed?.gain ?? 0), id).toBeLessThanOrEqual(0.9);
}
}
});

it('plays the same bar the same way every time', () => {
expect(LOOPS['hidden-garden'].voices(3)).toEqual(LOOPS['hidden-garden'].voices(3));
});

it('detunes the loop in the anomaly state', () => {
const clean = loopVoices('rusty-creek', 0, 'clean')[0];
const wrong = loopVoices('rusty-creek', 0, 'anomaly')[0];
expect(wrong.from).toBeCloseTo((clean.from ?? 0) * ANOMALY_DETUNE);
});

it('gives each slice region an ambient loop', () => {
expect(regionLoop('hidden-garden')).toBe('hidden-garden');
expect(regionLoop('rusty-creek')).toBe('rusty-creek');
});
});
Loading
Loading