Add a spoken countdown between frames
Some checks failed
CI / build-and-deploy (push) Has been cancelled

A 🔊 toggle on the home screen counts you into each frame - "five, four,
three, two, one" - plus start and finish announcements. Useful when you're in
front of the camera rather than at the laptop. Uses the Web Speech API, so
it's local and needs no configuration.

The narrator runs off the intervalometer's state updates rather than a timer
of its own, so it can't drift away from what the sequence is doing; each
second is spoken at most once even though state arrives ~5x/sec.

The count is capped at one second under the interval, so a 3s interval says
"two, one" instead of talking over the previous frame, and a 1s interval stays
silent. A start delay isn't clamped, since that gap is whatever you set.

Countdown length, voice choice and frame-number announcements live in the
settings drawer; only the toggle is on the home screen.

Also:
- The toggle sits in the status card rather than the button row: three buttons
  don't fit a 400px column, and it wrapped onto its own flex line where it
  stretched to the wrong height.
- tsconfig excludes dist/, which build-dist.sh fills with copies of these same
  files and which otherwise gets type-checked twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
This commit is contained in:
Jon
2026-08-01 21:02:00 +01:00
parent 1af4b215b2
commit 539e3cfb34
7 changed files with 363 additions and 12 deletions

View File

@@ -24,6 +24,7 @@ import { SettingsDrawer, loadPrefs, savePrefs } from './settings.js';
import { Intervalometer, formatDuration } from './intervalometer.js';
import { FrameSaver, WakeLock } from './storage.js';
import { detectBulbSupport, configValue } from './config-utils.js';
import { Narrator, supportsSpeech } from './voice.js';
export const isDebug = new URLSearchParams(location.search).has('debug');
@@ -42,6 +43,7 @@ class App extends Component {
saver = new FrameSaver();
wakeLock = new WakeLock();
narrator = new Narrator();
intervalometer = new Intervalometer({
capture: index => this.captureFrame(index),
@@ -67,6 +69,7 @@ class App extends Component {
componentDidMount() {
this.saver.mode = this.state.prefs.saveMode;
this.syncNarrator(this.state.prefs);
addEventListener('error', ({ message }) =>
this.log(`Uncaught error: ${message}`, 'error')
@@ -108,17 +111,47 @@ class App extends Component {
}));
}
/** @param {import('./settings.js').Prefs} prefs */
syncNarrator(prefs) {
Object.assign(this.narrator, {
enabled: prefs.voice,
countFrom: prefs.voiceCountFrom,
voiceURI: prefs.voiceURI,
announceFrames: prefs.voiceAnnounceFrames
});
}
/** @param {Partial<import('./settings.js').Prefs>} patch */
setPref = patch => {
this.setState(({ prefs }) => {
let next = { ...prefs, ...patch };
savePrefs(next);
this.saver.mode = next.saveMode;
this.syncNarrator(next);
if (!next.keepAwake) this.wakeLock.release();
return { prefs: next };
});
};
toggleVoice = () => {
let on = !this.state.prefs.voice;
// Applied straight away rather than waiting for the state update, so a
// countdown can't slip out between the click and the commit.
this.narrator.enabled = on;
this.setPref({ voice: on });
// Speaking here also gets the user gesture Chrome wants before it will
// let a page talk at all.
if (on) this.narrator.say('Voice countdown on');
else this.narrator.cancel();
};
testVoice = () => {
let wasEnabled = this.narrator.enabled;
this.narrator.enabled = true;
this.narrator.say('Three. Two. One.', { interrupt: true });
this.narrator.enabled = wasEnabled;
};
selectDevice = async () => {
// @ts-ignore
await Camera.showPicker();
@@ -259,6 +292,7 @@ class App extends Component {
/** @param {import('./intervalometer.js').SequenceState} seq */
handleSequenceChange(seq) {
let running = this.intervalometer.running;
this.narrator.update(seq);
this.setState({ seq });
if (this.#wasRunning && !running) {
this.wakeLock.release();
@@ -305,6 +339,8 @@ class App extends Component {
: ''
}.`
);
this.narrator.reset();
this.narrator.say('Starting');
this.intervalometer.start({
intervalMs: prefs.intervalSeconds * 1000,
count,
@@ -439,7 +475,9 @@ class App extends Component {
config: state.config,
hasFolder: this.saver.hasFolder,
canCapture: !!state.supportedOps?.captureImage,
log: state.log
log: state.log,
voiceSupported: supportsSpeech,
onToggleVoice: this.toggleVoice
})
),
h(SettingsDrawer, {
@@ -452,7 +490,9 @@ class App extends Component {
folderName: this.saver.folderName,
chooseFolder: this.chooseFolder,
bulbSupport: this.bulbSupport,
locked: running
locked: running,
voices: this.narrator.voices,
testVoice: this.testVoice
})
);
}