From 76ea1d0d7905c73cf59a342398f1dfd96886606e Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Thu, 20 Aug 2026 17:18:47 +0200 Subject: [PATCH] Sound was dead in the app because status 0 is not a failure; and back is a swipe to the RIGHT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PROJECT.md | 43 +++++++++++++++++++++------ index.html | 2 +- ios/App/App.xcodeproj/project.pbxproj | 2 ++ ios/App/App/AppDelegate.swift | 10 ++++++- src/audio.js | 23 ++++++++++---- src/main.js | 7 +++-- 6 files changed, 67 insertions(+), 20 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 558a5d6..3f73d0c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -2582,15 +2582,40 @@ inside a ` + diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index b0cd4e4..75fa23c 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -349,6 +349,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T789785GZK; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; @@ -369,6 +370,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = T789785GZK; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index c3cd83b..174f0b7 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -1,5 +1,6 @@ import UIKit import Capacitor +import AVFoundation @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -7,7 +8,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. + // PLAY THROUGH THE RING SWITCH. WKWebView's WebAudio runs in the SoloAmbient session by + // default, which the hardware silent switch mutes outright — so a phone that lives on silent + // (most of them) gets a completely silent game and nothing on screen explains why. `.playback` + // is the games-and-media category: audible on silent, and it is what makes the umpire and the + // menu theme reach a real device at all. The web build has no say in this; it is the whole + // reason the shell is worth having for sound. See PROJECT.md, "What the shell buys". + try? AVAudioSession.sharedInstance().setCategory(.playback) + try? AVAudioSession.sharedInstance().setActive(true) return true } diff --git a/src/audio.js b/src/audio.js index 17b6a52..7a37b9a 100644 --- a/src/audio.js +++ b/src/audio.js @@ -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 diff --git a/src/main.js b/src/main.js index ccca17e..cfa782c 100644 --- a/src/main.js +++ b/src/main.js @@ -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