From 8a31dfa4a4a2c26cc188298bcde19d581620860c Mon Sep 17 00:00:00 2001 From: marcosgilf Date: Fri, 14 Aug 2026 17:55:31 +0200 Subject: [PATCH] feat(audio): add count-down completion sound --- docs/spec.md | 19 +++- src/client/completion-sound.ts | 176 +++++++++++++++++++++++++++++++++ src/domain/cue.test.ts | 18 ++++ src/domain/cue.ts | 15 +++ src/pages/down.astro | 36 ++++++- src/styles/app.css | 12 ++- 6 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 src/client/completion-sound.ts create mode 100644 src/domain/cue.test.ts create mode 100644 src/domain/cue.ts diff --git a/docs/spec.md b/docs/spec.md index 8d6ba21..6a40e68 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -49,8 +49,9 @@ product shape and the order changed. ### Deferred until a shipped capability needs it -Cues and sound, settings, persistence, Wake Lock, skip/back transport, and configuring the prepare -countdown. Each stays specified below and returns as its own slice. +The remaining cue catalogue (countdown ticks, phase starts, final-round markers), settings, +persistence, Wake Lock, skip/back transport, and configuring the prepare countdown. Each stays specified +below and returns as its own slice. --- @@ -181,6 +182,20 @@ because nothing accumulates. Decided by Audio and haptic cue model, with the scheduling horizon revised by iOS background audio. +### Shipped subset — issue #21 + +The first audio slice ships one generated `routine-end` cue for count-down Routines. It is scheduled +from the explicit Start or Resume gesture at `AudioContext.currentTime + remainingMs / 1000`, uses an +`OscillatorNode` and `GainNode`, and is cancelled on pause, reset, restart, navigation, or a new Start. +Audio unlock failure is progressive enhancement: the Clock and Done screen continue normally, with a +one-line "Tap Start to enable sound." note when unlock is unavailable. All other cues, preferences, +and sinks remain future work. + +Before shipping, manually verify in iOS Safari and its installed PWA, plus Android Chrome and its +installed PWA: Start → finish, pause past the original deadline → Resume → finish, and Reset before +the deadline. Confirm device volume/silent controls follow each platform's audio policy and no stale +cue plays. + **Cues are data, sinks are dumb.** `cuesFor(routine)` is a pure function returning `{ at, kind }[]`; sinks (tone, vibration, later speech) render them. The pure part is unit-tested; sinks are trivial enough not to be. This split is the only thing that makes voice cues cheap later. diff --git a/src/client/completion-sound.ts b/src/client/completion-sound.ts new file mode 100644 index 0000000..ce07f02 --- /dev/null +++ b/src/client/completion-sound.ts @@ -0,0 +1,176 @@ +import { completionCueSchedule, type CompletionCueSchedule } from "../domain/cue.ts"; + +type AudioContextConstructor = new () => AudioContext; +type AudioSession = { type: string }; +type BrowserNavigator = Navigator & { audioSession?: AudioSession }; +type BrowserWindow = Window & { + AudioContext?: AudioContextConstructor; + webkitAudioContext?: AudioContextConstructor; +}; + +type ScheduledCue = { + oscillator: OscillatorNode; + gain: GainNode; +}; + +const getAudioContext = (): AudioContext | null => { + if (typeof window === "undefined") return null; + + const browserWindow = window as BrowserWindow; + const Context = browserWindow.AudioContext ?? browserWindow.webkitAudioContext; + if (!Context) return null; + + try { + return new Context(); + } catch { + return null; + } +}; + +const setAudioSessionType = (type: "auto" | "transient") => { + if (typeof navigator === "undefined") return; + + try { + const audioSession = (navigator as BrowserNavigator).audioSession; + if (audioSession) audioSession.type = type; + } catch { + // Audio session is progressive enhancement. Unsupported or rejected writes are harmless. + } +}; + +const disconnect = (node: AudioNode) => { + try { + node.disconnect(); + } catch { + // The node may already have ended or been disconnected by the browser. + } +}; + +const stop = (node: OscillatorNode) => { + try { + node.stop(); + } catch { + // An ended oscillator cannot be stopped twice. + } +}; + +const verifyUnlock = (context: AudioContext) => { + const oscillator = context.createOscillator(); + const gain = context.createGain(); + + try { + gain.gain.value = 0; + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.start(); + oscillator.stop(context.currentTime + 0.01); + } catch (error) { + stop(oscillator); + disconnect(oscillator); + disconnect(gain); + throw error; + } +}; + +const scheduleTone = (context: AudioContext, schedule: CompletionCueSchedule): ScheduledCue => { + const oscillator = context.createOscillator(); + const gain = context.createGain(); + const cue = { oscillator, gain }; + + try { + oscillator.type = "triangle"; + oscillator.frequency.setValueAtTime(880, schedule.at); + oscillator.frequency.exponentialRampToValueAtTime(440, schedule.stopAt); + + gain.gain.setValueAtTime(0.001, schedule.at); + gain.gain.linearRampToValueAtTime(0.28, schedule.at + 0.02); + gain.gain.exponentialRampToValueAtTime(0.001, schedule.stopAt); + + oscillator.connect(gain); + gain.connect(context.destination); + oscillator.start(schedule.at); + oscillator.stop(schedule.stopAt); + return cue; + } catch (error) { + stop(oscillator); + disconnect(oscillator); + disconnect(gain); + throw error; + } +}; + +export const createCompletionSound = () => { + let context: AudioContext | null = null; + let scheduled: ScheduledCue | null = null; + let requestVersion = 0; + + const cancelScheduled = () => { + const cue = scheduled; + scheduled = null; + if (!cue) return; + + stop(cue.oscillator); + disconnect(cue.oscillator); + disconnect(cue.gain); + }; + + const cancel = () => { + requestVersion += 1; + cancelScheduled(); + setAudioSessionType("auto"); + }; + + const start = async (remainingMs: number): Promise => { + cancel(); + if (!Number.isFinite(remainingMs) || remainingMs <= 0) return false; + + const version = requestVersion; + context ??= getAudioContext(); + if (!context) return false; + + setAudioSessionType("transient"); + + try { + await context.resume(); + if (version !== requestVersion || context.state !== "running") { + if (version === requestVersion) setAudioSessionType("auto"); + return false; + } + + verifyUnlock(context); + if (version !== requestVersion) return false; + + const schedule = completionCueSchedule(context.currentTime, remainingMs); + if (schedule.at <= context.currentTime) { + setAudioSessionType("auto"); + return true; + } + + const cue = scheduleTone(context, schedule); + cue.oscillator.addEventListener("ended", () => { + if (scheduled !== cue) return; + scheduled = null; + disconnect(cue.oscillator); + disconnect(cue.gain); + setAudioSessionType("auto"); + }); + scheduled = cue; + return true; + } catch { + if (version === requestVersion) setAudioSessionType("auto"); + return false; + } + }; + + const resume = async (remainingMs: number): Promise => { + if (context?.state === "running" && scheduled) return true; + return start(remainingMs); + }; + + const complete = () => { + requestVersion += 1; + setAudioSessionType("auto"); + }; + + return { start, resume, cancel, complete }; +}; diff --git a/src/domain/cue.test.ts b/src/domain/cue.test.ts new file mode 100644 index 0000000..68df391 --- /dev/null +++ b/src/domain/cue.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { COMPLETION_CUE_DURATION_SECONDS, completionCueSchedule } from "./cue.ts"; + +describe("count-down completion cue", () => { + it("books cue at current audio time plus derived remaining time", () => { + expect(completionCueSchedule(12.5, 2500)).toEqual({ + at: 15, + stopAt: 15 + COMPLETION_CUE_DURATION_SECONDS, + }); + }); + + it("never schedules before current audio time", () => { + expect(completionCueSchedule(12.5, -1)).toEqual({ + at: 12.5, + stopAt: 12.5 + COMPLETION_CUE_DURATION_SECONDS, + }); + }); +}); diff --git a/src/domain/cue.ts b/src/domain/cue.ts new file mode 100644 index 0000000..f8756fe --- /dev/null +++ b/src/domain/cue.ts @@ -0,0 +1,15 @@ +export const COMPLETION_CUE_DURATION_SECONDS = 0.24; + +export type CompletionCueSchedule = { + at: number; + stopAt: number; +}; + +/** Book count-down completion sound from audio clock, not a render callback. */ +export const completionCueSchedule = ( + currentTime: number, + remainingMs: number, +): CompletionCueSchedule => { + const at = currentTime + Math.max(0, remainingMs) / 1000; + return { at, stopAt: at + COMPLETION_CUE_DURATION_SECONDS }; +}; diff --git a/src/pages/down.astro b/src/pages/down.astro index f96e076..936e7aa 100644 --- a/src/pages/down.astro +++ b/src/pages/down.astro @@ -10,6 +10,7 @@ import AppLayout from "../layouts/AppLayout.astro"; +