/** * The railroad, heard. * * SYNTHESISED, NOT SAMPLED, and that is a constraint rather than a preference. The site is a static * folder that fetches nothing — there is a test asserting no page reaches an external host — so an * audio file would have to be committed to the repo, and a plausible-sounding whistle is not * something to invent. See TODO for sourcing real clips; these stand in until then, which is why * sound defaults to off. * * SELF-CONTAINED ON PURPOSE, like the two board renderers. The playable page imports this; the * replay is a single HTML file with an inline script and cannot import anything, so it embeds the * function via `Function.toString()`. One implementation either way — a second copy would drift, * and a replay that sounds different from the game is worse than a replay with no sound. * * The AudioContext is parked on `window` rather than in a module variable for the same reason: the * embedded copy has no module scope to keep it in, and browsers permit only a handful per page. */ export function playCue(cue: string): void { const w = window as unknown as { __smAudio?: AudioContext; AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext }; // "All aboard" is speech, and speech cannot be faked with oscillators. `speechSynthesis` is built // into the browser, needs no asset and works offline — but it is whatever system voice the player // has, so a real clip is the first thing to replace here. if (cue === 'train') { try { if (window.speechSynthesis && typeof SpeechSynthesisUtterance === 'function') { const u = new SpeechSynthesisUtterance('All aboard!'); u.rate = 0.95; u.pitch = 0.85; u.volume = 0.9; window.speechSynthesis.speak(u); return; } } catch { // no voice available — fall through to the two-note call below } } let c: AudioContext; try { const Ctor = w.AudioContext ?? w.webkitAudioContext; if (!Ctor) return; // Browsers refuse to start audio until the player has interacted with the page, so the context // is made on first use — by which time they have clicked something — and resumed if suspended. w.__smAudio ??= new Ctor(); c = w.__smAudio; if (c.state === 'suspended') void c.resume(); } catch { return; } /** A tone with an envelope, so nothing starts or stops with a click. */ const tone = (at: number, freq: number, dur: number, gain: number, type: OscillatorType, bendTo?: number): void => { const osc = c.createOscillator(); const amp = c.createGain(); osc.type = type; osc.frequency.setValueAtTime(freq, at); if (bendTo !== undefined) osc.frequency.linearRampToValueAtTime(bendTo, at + dur); amp.gain.setValueAtTime(0, at); amp.gain.linearRampToValueAtTime(gain, at + Math.min(0.04, dur / 4)); amp.gain.exponentialRampToValueAtTime(0.0001, at + dur); osc.connect(amp).connect(c.destination); osc.start(at); osc.stop(at + dur + 0.02); }; const t = c.currentTime + 0.02; if (cue === 'stage') { // A real steam whistle is a CHORD of several chambers slightly out of tune with each other, // plus the breath of the steam. The beating between the partials IS the sound. const d = 1.8; for (const fg of [[392, 0.16], [466, 0.13], [587, 0.1], [784, 0.05]]) { tone(t, fg[0]!, d, fg[1]!, 'sawtooth', fg[0]! * 0.985); } const frames = Math.floor(c.sampleRate * d); const buf = c.createBuffer(1, Math.max(1, frames), c.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1; const src = c.createBufferSource(); src.buffer = buf; const band = c.createBiquadFilter(); band.type = 'bandpass'; band.frequency.value = 1400; band.Q.value = 0.8; const amp = c.createGain(); amp.gain.setValueAtTime(0, t); amp.gain.linearRampToValueAtTime(0.05, t + 0.05); amp.gain.exponentialRampToValueAtTime(0.0001, t + d); src.connect(band).connect(amp).connect(c.destination); src.start(t); src.stop(t + d); return; } if (cue === 'couple') { /** * KNUCKLE COUPLERS MEETING: a low metallic clank and the slack running out behind it. * * Coupling a cut was reported as invisible — the car simply left the board and only history said * where it went. It is the single most consequential thing a switching move does, so it gets the * one sound a player will learn to listen for. */ for (const fg of [[86, 0.22], [131, 0.13], [197, 0.07], [61, 0.18]]) { tone(t, fg[0]!, 0.26, fg[1]!, 'square', fg[0]! * 0.82); } tone(t + 0.1, 74, 0.34, 0.1, 'triangle', 58); return; } if (cue === 'drop') { // Setting out is the same act in reverse and quieter: one clank, no slack behind it. for (const fg of [[104, 0.14], [156, 0.08]]) { tone(t, fg[0]!, 0.18, fg[1]!, 'square', fg[0]! * 0.85); } return; } if (cue === 'schedule') { // A die landing on the despatcher's desk, then the slot taking it: a short clatter and a chime. for (const at of [t, t + 0.05, t + 0.09]) tone(at, 200 + Math.random() * 90, 0.05, 0.07, 'square'); tone(t + 0.16, 880, 0.3, 0.09, 'sine'); tone(t + 0.16, 1320, 0.28, 0.05, 'sine'); return; } if (cue === 'completed') { // A train clearing the Division and whistling off: two long horn notes falling away, with the // second quieter and lower so it reads as distance rather than repetition. Longer than any other // cue on purpose — it is the only one that pays every player, and it should land as an event. tone(t, 392, 0.55, 0.13, 'sawtooth', 370); tone(t, 262, 0.55, 0.09, 'triangle', 247); tone(t + 0.42, 330, 0.75, 0.08, 'sawtooth', 300); tone(t + 0.42, 220, 0.75, 0.055, 'triangle', 200); return; } if (cue === 'day') { // The grade-crossing bell: two strikes. Inharmonic partials separate a bell from a beep. for (const at of [t, t + 0.22]) { for (const fg of [[1046, 0.13], [1570, 0.09], [2350, 0.06], [3140, 0.03]]) { tone(at, fg[0]!, 0.4, fg[1]!, 'square'); } } return; } if (cue === 'train') { // Reached only when the platform has no voice: a two-note conductor's call rather than silence. tone(t, 523, 0.22, 0.14, 'triangle'); tone(t + 0.2, 784, 0.34, 0.14, 'triangle'); } }