Sound was dead in the app because status 0 is not a failure; and back is a swipe to the RIGHT

Oliver on the first shell build: *"I don't hear any of the games sounds.. daniel the umpire is
synthesized which is horrible and no music is playing on start"* — two independent causes.

- **Opaque fetch.** Capacitor serves the bundle through a custom WKURLSchemeHandler, and `fetch`
  reports those responses as `status: 0, ok: false` with the whole body present and decoding
  perfectly. All three loads in `audio.js` guarded with `if (!r.ok) throw`, so inside the app every
  recorded sound failed at the door: the umpire fell through to the synthesized `say()`, no bed
  started, no sample played. The synthesized sfx kept working, which made it read as a mixing bug
  rather than a loader one. Fixed once, in the shared `bytes()` helper every audio fetch now routes
  through — a status of 0 is the ABSENCE of an HTTP status, so trust it only when there is one and
  a real 404 still falls back. Any future `!r.ok` on a bundle-relative fetch is this same bug.
- **The ring switch.** WKWebView's WebAudio runs in the SoloAmbient session, which the hardware
  silent switch mutes outright — a silent game on a phone that lives on silent, with nothing on
  screen to explain it. AppDelegate now sets AVAudioSession `.playback`, the games category.

Confirmed in the shell: the bed reaches PLAYING, the context is `running` with no gesture at all
(autoplay really is allowed in WKWebView, as LATER predicted), and `say()` never fires. On the web,
a genuinely missing file still falls back — checked both.

And the swipe flips: **right goes back**, the way iOS's own pop gesture goes (*"that's the iOS
way"*). Left now does nothing. Noted in PROJECT.md that on a platform gesture the platform's
convention beats the literal wording of the brief, and that is worth saying up front.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-20 17:18:47 +02:00
1 parent d71a26831a
commit 76ea1d0d79
6 files changed
+67 -20

No files matched your search

+17 -6
View File
@@ -7,6 +7,20 @@ export const audio = () => {
return AC;
};
// EVERY audio file is fetched through here, and the reason is one line of it. Inside the Capacitor
// shell the bundle is served by a custom WKURLSchemeHandler, and `fetch` reports those responses as
// OPAQUE: `status` 0 and `ok` false, even though the bytes are all present and decode perfectly.
// The old guard was a plain `if (!r.ok) throw` — so in the app EVERY recorded sound failed at the
// door: the umpire fell through to the synthesized speech fallback, no bed ever started, and not a
// single sample played, while the synthesized sfx kept working and made it look like a mixing bug
// (Oliver, 2026-08-20, on the first shell build: *"daniel the umpire is synthesized which is
// horrible and no music is playing on start"*). A status of 0 is not a failure, it is the absence
// of an HTTP status — so trust the status only when there IS one, and let a real 404 still throw.
const bytes = url => fetch(url).then(r => {
if (r.status && !r.ok) throw new Error(url + ' ' + r.status);
return r.arrayBuffer();
});
// Umpire lines are pre-recorded files (assets/audio/*.m4a) played via WebAudio —
// works on mobile where speechSynthesis is unreliable. say() remains the fallback.
const VOICE = {};
@@ -48,8 +62,7 @@ export function voice(name, delay = 0, rate = 1) {
function loadVoice(name) {
if (VOICE[name]) return Promise.resolve(VOICE[name]);
return fetch('assets/audio/' + name + '.m4a')
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
return bytes('assets/audio/' + name + '.m4a')
.then(ab => audio().decodeAudioData(ab))
.then(buf => (VOICE[name] = buf));
}
@@ -119,8 +132,7 @@ export function sfx(name, vol = 1, opts = {}) {
};
if (SFX[name]) return play(SFX[name]);
// a name with an extension is used as-is (weapon-click2.mp3); bare names keep the .wav default
fetch('assets/audio/' + (name.includes('.') ? name : name + '.wav'))
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
bytes('assets/audio/' + (name.includes('.') ? name : name + '.wav'))
.then(ab => ac.decodeAudioData(ab))
.then(buf => { SFX[name] = buf; play(buf); })
.catch(() => {}); // missing sample = silence, never a thrown error mid-rally
@@ -141,8 +153,7 @@ function loopBed(file, lowcut, vol, loopEnd = 0) {
const ac = audio();
if (beds.has(file)) return;
beds.set(file, null);
fetch('assets/audio/' + file) // full filename: the beds are a mix of .wav and .m4a, see below
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
bytes('assets/audio/' + file) // full filename: the beds are a mix of .wav and .m4a, see below
.then(ab => ac.decodeAudioData(ab))
.then(buf => {
// RESUME AGAIN, HERE. The gesture that unlocked the context is long gone by now: the menu
+4 -3
View File
@@ -537,7 +537,8 @@ document.getElementById('sBack').addEventListener('click', () => {
});
document.getElementById('bsBack').addEventListener('click', () => go('secret'));
// SWIPE TO GO BACK. Every screen but the splash wears exactly one chevron in its header (.hdr in
// SWIPE TO GO BACK — RIGHTWARD, the way iOS's own pop gesture goes (Oliver, 2026-08-20: *"that's
// the iOS way"*). Every screen but the splash wears exactly one chevron in its header (.hdr in
// index.html) and that button ALREADY knows where back is for its own screen — the wizard's shape
// lives in the handlers above and nowhere else. So the gesture finds the one visible chevron and
// clicks it, rather than keeping a second map that would drift the first time a stage is added.
@@ -553,8 +554,8 @@ intro.addEventListener('touchend', e => {
if (!from || e.touches.length) return;
const t = e.changedTouches[0];
const dx = t.clientX - from.x, dy = t.clientY - from.y;
// leftward, and clearly horizontal: the stats and board screens scroll vertically under the finger
if (dx > -SWIPE_MIN || Math.abs(dy) > Math.abs(dx) * 0.7) return;
// rightward, and clearly horizontal: the stats and board screens scroll vertically under the finger
if (dx < SWIPE_MIN || Math.abs(dy) > Math.abs(dx) * 0.7) return;
const back = [...intro.querySelectorAll('.chev')].find(b => b.offsetParent); // the one on screen
if (!back) return;
// NOT passive, and this is why: a swipe that starts and ends inside one of the big poster cards