Skip to content

Commit 60cabab

Browse files
TMHSDigitalclaude
andcommitted
Define sounds as a recipe table and route them through one master gain
Following the Game-Systems web-sfx spec: click, error, and success are recipes (wave, frequency and slide, duration, volume), and the success arpeggio uses a steps list with start offsets. Every voice goes through a master gain node, at most 16 play at once (the oldest is stopped), exponential ramps stop at 0.0001 instead of 0, nodes disconnect when they end, clicks get a few percent of pitch jitter, and a missing or blocked AudioContext turns sound off instead of throwing. The context was already created on first use rather than at load. Fixes #1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fc1f46c commit 60cabab

3 files changed

Lines changed: 171 additions & 69 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ Open `index.html` in a browser, or serve the folder with any static file server.
3939
- **Stats**: a dialog, opened from the chart icon in the header, with your best WPM for every setting you have played, the average of your last ten runs, and a list of recorded runs. Export saves everything in `localStorage` to a JSON file, Import loads such a file and replaces the stored data, and Clear data (two clicks) removes history, bests, and key stats.
4040
- **Themes**: System (follows the OS light/dark and increased-contrast settings), Dark, Light, and High contrast, chosen in Settings. `theme.js`, loaded in `<head>`, applies the saved theme before the page paints.
4141
- **Offline and install**: `sw.js`, a service worker, saves the app's files on the first visit, so later visits load without a network connection. It tries the network first, so a new version is used as soon as you are online. `manifest.webmanifest` and the icons in `icons/` let browsers install the page as an app.
42-
- **Sound**: short tones made with the Web Audio API for keypress, mistake, and completion. Can be turned off.
42+
- **Sound**: short tones made with the Web Audio API for keypress, mistake, and completion, defined as a table of recipes in `script.js`. The audio context is created on the first keystroke, at most 16 tones play at once, and nothing is created while sound is off. Can be turned off.
4343
- **Keyboard**: Tab restarts while you are typing. Escape closes a dialog. Opening Settings during a run abandons the run.
4444
- **Focus**: if the typing input loses focus, a hint appears over the text. Pressing a letter key moves focus back to the input.
4545
- **Screen readers**: the passage is available as the input's description, settings buttons report whether they are selected, and the result dialog reads out WPM and accuracy.

‎script.js‎

Lines changed: 81 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -261,73 +261,101 @@ function currentBest() {
261261
}
262262

263263
// --- Audio ---
264-
// Created on first use so the browser does not warn about an AudioContext
265-
// started before any user gesture.
266-
let audioContext = null;
264+
// Each sound is a recipe: oscillator shape, start and end frequency,
265+
// duration, and volume. `steps` lists sub-recipes with start offsets in
266+
// seconds, for sounds made of several notes.
267+
const SOUNDS = {
268+
click: { wave: 'sine', freq: 800, toFreq: 400, duration: 0.05, volume: 0.05, jitter: 0.03 },
269+
error: {
270+
wave: 'sawtooth',
271+
freq: 100,
272+
toFreq: 80,
273+
slide: 'linear',
274+
duration: 0.15,
275+
volume: 0.08,
276+
},
277+
// C5, E5, G5, C6
278+
success: {
279+
steps: [523.25, 659.25, 783.99, 1046.5].map((freq, i) => ({
280+
at: i * 0.1,
281+
wave: 'sine',
282+
freq,
283+
duration: 0.5,
284+
volume: 0.05,
285+
})),
286+
},
287+
};
288+
289+
const MAX_VOICES = 16; // oldest voice is stopped beyond this
290+
const GAIN_FLOOR = 0.0001; // exponential ramps cannot reach 0
291+
292+
// The context is created on the first sound, after a keystroke, so the
293+
// browser does not start it suspended. Every voice goes through one master
294+
// gain node. If Web Audio is missing or blocked, sounds are skipped.
295+
const audio = { context: null, master: null, failed: false, voices: [] };
267296

268297
function getAudioContext() {
269-
if (!audioContext) {
270-
const Ctx = window.AudioContext || window.webkitAudioContext;
271-
if (!Ctx) return null;
272-
audioContext = new Ctx();
298+
if (audio.failed) return null;
299+
if (!audio.context) {
300+
try {
301+
const Ctx = window.AudioContext || window.webkitAudioContext;
302+
audio.context = new Ctx();
303+
audio.master = audio.context.createGain();
304+
audio.master.connect(audio.context.destination);
305+
} catch {
306+
audio.failed = true;
307+
audio.context = null;
308+
return null;
309+
}
273310
}
274-
if (audioContext.state === 'suspended') audioContext.resume();
275-
return audioContext;
311+
if (audio.context.state === 'suspended') audio.context.resume().catch(() => {});
312+
return audio.context;
276313
}
277314

278-
function playTone(ctx, { type, startFreq, endFreq, ramp, gain, duration, at }) {
315+
function playVoice(ctx, recipe, at) {
316+
while (audio.voices.length >= MAX_VOICES) audio.voices.shift().stop();
317+
318+
// A few percent of random pitch keeps repeated clicks from sounding identical.
319+
const jitter = recipe.jitter ? 1 + (Math.random() * 2 - 1) * recipe.jitter : 1;
320+
const duration = recipe.duration > 0 ? recipe.duration : 0.1;
279321
const osc = ctx.createOscillator();
280-
const gainNode = ctx.createGain();
281-
osc.connect(gainNode);
282-
gainNode.connect(ctx.destination);
283-
osc.type = type;
284-
osc.frequency.setValueAtTime(startFreq, at);
285-
if (endFreq !== undefined) {
286-
if (ramp === 'linear') osc.frequency.linearRampToValueAtTime(endFreq, at + duration);
287-
else osc.frequency.exponentialRampToValueAtTime(endFreq, at + duration);
322+
const gain = ctx.createGain();
323+
osc.connect(gain);
324+
gain.connect(audio.master);
325+
osc.type = recipe.wave || 'square';
326+
osc.frequency.setValueAtTime(Math.max(recipe.freq * jitter, 1), at);
327+
if (recipe.toFreq !== undefined) {
328+
const target = Math.max(recipe.toFreq * jitter, 1);
329+
if (recipe.slide === 'linear') osc.frequency.linearRampToValueAtTime(target, at + duration);
330+
else osc.frequency.exponentialRampToValueAtTime(target, at + duration);
288331
}
289-
gainNode.gain.setValueAtTime(gain, at);
290-
gainNode.gain.exponentialRampToValueAtTime(0.001, at + duration);
332+
gain.gain.setValueAtTime(Math.max(recipe.volume ?? 0.08, GAIN_FLOOR), at);
333+
gain.gain.exponentialRampToValueAtTime(GAIN_FLOOR, at + duration);
291334
osc.start(at);
292335
osc.stop(at + duration);
336+
337+
audio.voices.push(osc);
338+
osc.onended = () => {
339+
osc.disconnect();
340+
gain.disconnect();
341+
const index = audio.voices.indexOf(osc);
342+
if (index !== -1) audio.voices.splice(index, 1);
343+
};
293344
}
294345

295-
function playSound(type) {
346+
// Fire and forget: never throws, and returns before touching Web Audio when
347+
// sound is off.
348+
function playSound(name) {
296349
if (!gameState.isSoundEnabled) return;
350+
const recipe = SOUNDS[name];
351+
if (!recipe) return;
297352
const ctx = getAudioContext();
298353
if (!ctx) return;
299-
const now = ctx.currentTime;
300-
301-
if (type === 'click') {
302-
playTone(ctx, {
303-
type: 'sine',
304-
startFreq: 800,
305-
endFreq: 400,
306-
gain: 0.05,
307-
duration: 0.05,
308-
at: now,
309-
});
310-
} else if (type === 'error') {
311-
playTone(ctx, {
312-
type: 'sawtooth',
313-
startFreq: 100,
314-
endFreq: 80,
315-
ramp: 'linear',
316-
gain: 0.08,
317-
duration: 0.15,
318-
at: now,
319-
});
320-
} else if (type === 'success') {
321-
// C5, E5, G5, C6
322-
[523.25, 659.25, 783.99, 1046.5].forEach((note, i) => {
323-
playTone(ctx, {
324-
type: 'sine',
325-
startFreq: note,
326-
gain: 0.05,
327-
duration: 0.5,
328-
at: now + i * 0.1,
329-
});
330-
});
354+
try {
355+
const now = ctx.currentTime;
356+
for (const step of recipe.steps || [recipe]) playVoice(ctx, step, now + (step.at || 0));
357+
} catch {
358+
// A failed sound must not interrupt typing.
331359
}
332360
}
333361

‎tests/typespeed.spec.js‎

Lines changed: 89 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
// browser, where script.js's top-level consts are in scope.
33
/* global runtime, gameState, resultModal, typingInputElement, wpmElement, updateMetrics,
44
getPassage, settingsKey, COMMON_WORDS, QUOTES, renderNewQuote, parseChallenge,
5-
decodeChallenge, encodeChallenge, normalizeCustomText, CODE_SNIPPETS */
5+
decodeChallenge, encodeChallenge, normalizeCustomText, CODE_SNIPPETS, playSound, playVoice,
6+
getAudioContext, audio, SOUNDS */
67

78
const { test, expect } = require('@playwright/test');
89
const { default: AxeBuilder } = require('@axe-core/playwright');
@@ -626,27 +627,46 @@ test('hard mode blurs typed characters and leaves the rest readable', async ({ p
626627
test.describe('sound', () => {
627628
// Counts AudioContexts and tones without making noise.
628629
const stubAudio = () => {
629-
window.audio = { contexts: 0, tones: 0 };
630-
const param = {
631-
setValueAtTime() {},
632-
linearRampToValueAtTime() {},
633-
exponentialRampToValueAtTime() {},
634-
};
630+
window.audioCalls = { contexts: 0, tones: 0, stopped: 0, rampTargets: [] };
631+
const calls = window.audioCalls;
632+
const param = () => ({
633+
setValueAtTime(value) {
634+
calls.rampTargets.push(value);
635+
},
636+
linearRampToValueAtTime(value) {
637+
calls.rampTargets.push(value);
638+
},
639+
exponentialRampToValueAtTime(value) {
640+
calls.rampTargets.push(value);
641+
},
642+
});
643+
// Oscillators never end on their own here, so the voice cap is what
644+
// limits how many stay alive.
635645
class FakeAudioContext {
636646
constructor() {
637-
window.audio.contexts++;
647+
calls.contexts++;
638648
this.state = 'running';
639649
this.currentTime = 0;
640650
this.destination = {};
641651
}
642652
createOscillator() {
643-
window.audio.tones++;
644-
return { connect() {}, frequency: param, start() {}, stop() {} };
653+
calls.tones++;
654+
return {
655+
frequency: param(),
656+
connect() {},
657+
disconnect() {},
658+
start() {},
659+
stop() {
660+
calls.stopped++;
661+
},
662+
};
645663
}
646664
createGain() {
647-
return { connect() {}, gain: param };
665+
return { gain: param(), connect() {}, disconnect() {} };
666+
}
667+
resume() {
668+
return Promise.resolve();
648669
}
649-
resume() {}
650670
}
651671
window.AudioContext = FakeAudioContext;
652672
window.webkitAudioContext = FakeAudioContext;
@@ -655,10 +675,17 @@ test.describe('sound', () => {
655675
test('creates the audio context on the first keystroke, not at load', async ({ page }) => {
656676
await page.addInitScript(stubAudio);
657677
await page.goto('/');
658-
expect(await page.evaluate(() => window.audio)).toEqual({ contexts: 0, tones: 0 });
678+
const counts = () =>
679+
page.evaluate(() => ({
680+
contexts: window.audioCalls.contexts,
681+
tones: window.audioCalls.tones,
682+
}));
683+
expect(await counts()).toEqual({ contexts: 0, tones: 0 });
659684

660685
await input(page).press((await currentQuote(page))[0]);
661-
expect(await page.evaluate(() => window.audio)).toEqual({ contexts: 1, tones: 1 });
686+
expect(await counts()).toEqual({ contexts: 1, tones: 1 });
687+
await input(page).press((await currentQuote(page))[1]);
688+
expect(await counts()).toEqual({ contexts: 1, tones: 2 });
662689
});
663690

664691
test('plays nothing when turned off', async ({ page }) => {
@@ -668,7 +695,54 @@ test.describe('sound', () => {
668695
});
669696
await page.goto('/');
670697
await input(page).pressSequentially((await currentQuote(page)).slice(0, 5));
671-
expect(await page.evaluate(() => window.audio)).toEqual({ contexts: 0, tones: 0 });
698+
expect(
699+
await page.evaluate(() => [window.audioCalls.contexts, window.audioCalls.tones])
700+
).toEqual([0, 0]);
701+
});
702+
703+
test('caps live voices at 16 and stops the oldest', async ({ page }) => {
704+
await page.addInitScript(stubAudio);
705+
await page.goto('/');
706+
const result = await page.evaluate(() => {
707+
for (let n = 0; n < 100; n++) playSound('click');
708+
return { live: audio.voices.length, stopped: window.audioCalls.stopped };
709+
});
710+
expect(result.live).toBe(16);
711+
// Each voice calls stop() once when scheduled; 84 more were stopped early.
712+
expect(result.stopped).toBe(100 + 84);
713+
});
714+
715+
test('recipes compile to positive frequencies, durations, and ramp targets', async ({
716+
page,
717+
}) => {
718+
await page.addInitScript(stubAudio);
719+
await page.goto('/');
720+
const result = await page.evaluate(() => {
721+
const steps = Object.values(SOUNDS).flatMap((recipe) => recipe.steps || [recipe]);
722+
const valid = steps.every((step) => step.freq > 0 && step.duration > 0);
723+
// A recipe sliding to 0 Hz at volume 0 must not throw or ramp to 0.
724+
getAudioContext();
725+
window.audioCalls.rampTargets = [];
726+
playVoice(audio.context, { freq: 440, toFreq: 0, volume: 0, duration: 0.1 }, 0);
727+
return { valid, targets: window.audioCalls.rampTargets };
728+
});
729+
expect(result.valid).toBe(true);
730+
expect(result.targets.length).toBeGreaterThan(0);
731+
for (const value of result.targets) expect(value).toBeGreaterThan(0);
732+
});
733+
734+
test('a missing or blocked AudioContext is skipped without errors', async ({ page }) => {
735+
await page.addInitScript(() => {
736+
window.AudioContext = function () {
737+
throw new Error('blocked');
738+
};
739+
window.webkitAudioContext = undefined;
740+
});
741+
const errors = collectErrors(page);
742+
await page.goto('/');
743+
await input(page).pressSequentially((await currentQuote(page)).slice(0, 3));
744+
expect(await page.evaluate(() => [audio.failed, runtime.typedChars])).toEqual([true, 3]);
745+
expect(errors).toEqual([]);
672746
});
673747
});
674748

0 commit comments

Comments
 (0)