Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.
Expand Down
176 changes: 176 additions & 0 deletions src/client/completion-sound.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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<boolean> => {
if (context?.state === "running" && scheduled) return true;
return start(remainingMs);
};

const complete = () => {
requestVersion += 1;
setAudioSessionType("auto");
};

return { start, resume, cancel, complete };
};
18 changes: 18 additions & 0 deletions src/domain/cue.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
15 changes: 15 additions & 0 deletions src/domain/cue.ts
Original file line number Diff line number Diff line change
@@ -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 };
};
36 changes: 33 additions & 3 deletions src/pages/down.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import AppLayout from "../layouts/AppLayout.astro";
<TimerDisplay />

<p class="wake-lock-error" data-wake-lock-error role="alert" hidden></p>
<p class="audio-note" data-audio-note hidden>Tap Start to enable sound.</p>

<p class="finished" data-finished aria-hidden="true">
Done count down: <time data-configured-duration>05:00</time>
Expand All @@ -30,6 +31,7 @@ import AppLayout from "../layouts/AppLayout.astro";
import { announceElapsed, formatElapsed, formatRemaining } from "../domain/format.ts";
import { countdown, DEFAULT_DURATION_MS, durationFromParts } from "../domain/routine.ts";
import { phaseAt } from "../domain/phase.ts";
import { createCompletionSound } from "../client/completion-sound.ts";
import { createScreenWakeLock, type ScreenWakeLockManager } from "../client/wake-lock.ts";

const params = new URLSearchParams(location.search);
Expand All @@ -38,7 +40,6 @@ import AppLayout from "../layouts/AppLayout.astro";
? durationFromParts(Number(params.get("minutes") ?? 0), Number(params.get("seconds") ?? 0))
: DEFAULT_DURATION_MS;
const routine = countdown(durationMs);
const startsPaused = params.has("paused");
const startsDone = params.has("done");

const display = document.querySelector<HTMLTimeElement>("[data-display]")!;
Expand All @@ -50,12 +51,15 @@ import AppLayout from "../layouts/AppLayout.astro";
const configuredDuration = document.querySelector<HTMLTimeElement>("[data-configured-duration]")!;
const announcer = document.querySelector<HTMLElement>("[data-announcer]")!;
const wakeLockError = document.querySelector<HTMLElement>("[data-wake-lock-error]")!;
const audioNote = document.querySelector<HTMLElement>("[data-audio-note]")!;
const configurationLink = document.querySelector<HTMLAnchorElement>(".app-bar-action a")!;
const completionSound = createCompletionSound();

let clock: Clock = stopped;
let isFinished = startsDone;
let frame = 0;
let pageTitle = "";
let soundRequest = 0;

const wakeLock = createScreenWakeLock(
() => {
Expand Down Expand Up @@ -88,6 +92,22 @@ import AppLayout from "../layouts/AppLayout.astro";
void wakeLock.release();
};

const cancelSound = () => {
soundRequest += 1;
completionSound.cancel();
audioNote.hidden = true;
};

const scheduleSound = (resumeExisting = false) => {
const request = ++soundRequest;
const remainingMs = routine.durationMs - elapsedMs(clock, Date.now());
const schedule = resumeExisting ? completionSound.resume(remainingMs) : completionSound.start(remainingMs);
void schedule.then((enabled) => {
if (request !== soundRequest || isFinished || !isRunning(clock)) return;
audioNote.hidden = enabled;
});
};

const announce = (message: string) => {
announcer.textContent = message;
};
Expand Down Expand Up @@ -130,6 +150,8 @@ import AppLayout from "../layouts/AppLayout.astro";
const finish = () => {
isFinished = true;
clock = pause(clock, Date.now());
completionSound.complete();
audioNote.hidden = true;
releaseWakeLock();
stopTicking();
setUrlState("done");
Expand Down Expand Up @@ -158,6 +180,7 @@ import AppLayout from "../layouts/AppLayout.astro";
isFinished = false;
setUrlState("running");
clock = start(reset(), Date.now());
scheduleSound();
void refreshWakeLock();
stopTicking();
announce(`Started. ${announceElapsed(durationMs)} configured.`);
Expand All @@ -174,6 +197,7 @@ import AppLayout from "../layouts/AppLayout.astro";
const now = Date.now();
if (isRunning(clock)) {
clock = pause(clock, now);
cancelSound();
releaseWakeLock();
setUrlState("paused");
stopTicking();
Expand All @@ -188,6 +212,7 @@ import AppLayout from "../layouts/AppLayout.astro";
}

clock = resume(clock, now);
scheduleSound();
void refreshWakeLock();
setUrlState("running");
announce("Resumed.");
Expand All @@ -199,6 +224,7 @@ import AppLayout from "../layouts/AppLayout.astro";
setUrlState("running");
clock = reset();
isFinished = false;
cancelSound();
releaseWakeLock();
stopTicking();
announce("Reset.");
Expand All @@ -220,6 +246,7 @@ import AppLayout from "../layouts/AppLayout.astro";
return;
}
if (isRunning(clock)) {
scheduleSound(true);
void refreshWakeLock();
stopTicking();
tick();
Expand All @@ -235,6 +262,7 @@ import AppLayout from "../layouts/AppLayout.astro";
return;
}
confirmedNavigation = true;
cancelSound();
releaseWakeLock();
});
});
Expand All @@ -244,8 +272,10 @@ import AppLayout from "../layouts/AppLayout.astro";
event.preventDefault();
});

window.addEventListener("pagehide", releaseWakeLock);
window.addEventListener("pagehide", () => {
cancelSound();
releaseWakeLock();
});

paint();
if (!startsPaused && !startsDone) startRunning();
</script>
Loading