From 539e3cfb346dd4299feb04832b2706ee234afd8a Mon Sep 17 00:00:00 2001 From: Jon Date: Sat, 1 Aug 2026 21:02:00 +0100 Subject: [PATCH] Add a spoken countdown between frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb --- README.md | 2 + examples/preact/home.js | 46 ++++++++-- examples/preact/index.html | 30 +++++++ examples/preact/index.js | 44 +++++++++- examples/preact/settings.js | 93 +++++++++++++++++++- examples/preact/tsconfig.json | 3 + examples/preact/voice.js | 157 ++++++++++++++++++++++++++++++++++ 7 files changed, 363 insertions(+), 12 deletions(-) create mode 100644 examples/preact/voice.js diff --git a/README.md b/README.md index 57b1b0e..dc54223 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Open the printed URL in Chrome, plug the camera in over USB, switch it on and pi - **Interval**, **frames** (or ∞ for open-ended) and **start delay** - Start/stop, plus a single-shot button for framing - Live countdown to the next frame, progress, estimated finish time, and a log of the last few events +- A πŸ”Š toggle that speaks the countdown into each frame ("five, four, three, two, one") along with start and finish, for when you're in front of the camera rather than at the laptop - A read-only strip showing shutter / aperture / ISO / format / battery / shots remaining, so you can sanity-check exposure against the interval without opening anything - Pre-flight warnings when the interval is too tight for the current exposure and transfer time, when you're shooting RAW on a short interval, or when no output folder is set @@ -73,6 +74,7 @@ Open the printed URL in Chrome, plug the camera in over USB, switch it on and pi - Where frames go: a folder on disk via the File System Access API, one-at-a-time downloads, or nothing at all if you're keeping them on the card. Frames are named after the moment they were taken β€” `20260801-172713_00007.JPG` in a sequence, `20260801-172713.JPG` for a single shot β€” so sorting by name is sorting by capture time. The camera's own `IMG_1234` is dropped: it wraps at 9999 and resets on a card format, so it can't order a long run. - Live view on/off, and whether it stays up between frames during a sequence (on by default β€” the feed drops only while each shot fires, since the camera needs live view down to take it, then recovers on its own) - Screen wake lock, so a backgrounded tab doesn't get its timers throttled mid-run +- Voice countdown detail: how many seconds out to start counting (capped at one second under the interval so it never talks over the previous frame), which system voice to use, and whether to announce frame numbers - Bulb exposures, if the camera exposes `bulb` or `eosremoterelease` - The camera's own settings: shutter, aperture, ISO, image format, capture target, drive mode, and so on diff --git a/examples/preact/home.js b/examples/preact/home.js index dd3cf3d..99b69df 100644 --- a/examples/preact/home.js +++ b/examples/preact/home.js @@ -89,9 +89,14 @@ function sequenceWarnings(prefs, config, hasFolder) { } /** - * @param {{ state: SequenceState, prefs: Prefs }} props + * @param {{ + * state: SequenceState, + * prefs: Prefs, + * voiceSupported: boolean, + * onToggleVoice: () => void + * }} props */ -function SequenceStatus({ state, prefs }) { +function SequenceStatus({ state, prefs, voiceSupported, onToggleVoice }) { let { phase, taken, total } = state; let headline; let detail; @@ -135,8 +140,31 @@ function SequenceStatus({ state, prefs }) { return h( 'div', { class: `sequence-status phase-${phase}` }, - h('div', { class: 'headline' }, headline), - h('div', { class: 'detail' }, detail), + h( + 'div', + { class: 'status-head' }, + h( + 'div', + { class: 'status-text' }, + h('div', { class: 'headline' }, headline), + h('div', { class: 'detail' }, detail) + ), + // Sits with the countdown it speaks, rather than crowding the button row. + voiceSupported + ? h( + 'button', + { + type: 'button', + class: `voice-toggle ${prefs.voice ? 'on' : ''}`, + onclick: onToggleVoice, + title: prefs.voice + ? 'Voice countdown on β€” click to mute' + : 'Voice countdown off β€” click to speak the countdown before each frame' + }, + prefs.voice ? 'πŸ”Š' : 'πŸ”‡' + ) + : undefined + ), total ? h( 'div', @@ -166,7 +194,9 @@ function SequenceStatus({ state, prefs }) { * config: import('web-gphoto2').Config | undefined, * hasFolder: boolean, * canCapture: boolean, - * log: { id: number, message: string, kind: string, at: number }[] + * log: { id: number, message: string, kind: string, at: number }[], + * voiceSupported: boolean, + * onToggleVoice: () => void * }} props */ export function Home({ @@ -181,7 +211,9 @@ export function Home({ config, hasFolder, canCapture, - log + log, + voiceSupported, + onToggleVoice }) { let warnings = running ? [] : sequenceWarnings(prefs, config, hasFolder); let plannedSeconds = prefs.unlimited @@ -198,7 +230,7 @@ export function Home({ return h( 'div', { id: 'home' }, - h(SequenceStatus, { state, prefs }), + h(SequenceStatus, { state, prefs, voiceSupported, onToggleVoice }), h( 'div', diff --git a/examples/preact/index.html b/examples/preact/index.html index 87eefbc..7a1c7aa 100644 --- a/examples/preact/index.html +++ b/examples/preact/index.html @@ -353,14 +353,44 @@ .actions { display: flex; + flex-wrap: wrap; gap: 8px; margin-top: 12px; } + /* Labels shouldn't fold onto a second line to make room for a sibling - + the row wraps instead. */ + .actions button { + white-space: nowrap; + } + .actions .big { flex: 1; } + .status-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + } + + .status-text { + min-width: 0; + } + + .voice-toggle { + flex: none; + padding: 6px 10px; + font-size: 18px; + line-height: 1.2; + } + + .voice-toggle.on { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + } + /* ---------- readout & log ---------- */ .readout { diff --git a/examples/preact/index.js b/examples/preact/index.js index ee01650..328ad10 100644 --- a/examples/preact/index.js +++ b/examples/preact/index.js @@ -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} 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 }) ); } diff --git a/examples/preact/settings.js b/examples/preact/settings.js index e668275..b0d70ae 100644 --- a/examples/preact/settings.js +++ b/examples/preact/settings.js @@ -19,6 +19,7 @@ import { h, Component, Fragment } from 'preact'; import { Widget } from './widget.js'; import { supportsFolderSaving } from './storage.js'; +import { supportsSpeech } from './voice.js'; const PREFS_KEY = 'web-dslr.prefs'; @@ -38,7 +39,12 @@ export const DEFAULT_PREFS = { liveViewBetweenFrames: true, keepAwake: true, bulbEnabled: false, - bulbSeconds: 30 + bulbSeconds: 30, + // Toggled from the home screen; the rest live in this drawer. + voice: false, + voiceCountFrom: 5, + voiceURI: '', + voiceAnnounceFrames: false }; /** @typedef {typeof DEFAULT_PREFS} Prefs */ @@ -95,7 +101,9 @@ function Row({ label, hint, children }) { * folderName: string | undefined, * chooseFolder: () => void, * bulbSupport: ReturnType, - * locked: boolean + * locked: boolean, + * voices: SpeechSynthesisVoice[], + * testVoice: () => void * }> */ export class SettingsDrawer extends Component { @@ -122,7 +130,9 @@ export class SettingsDrawer extends Component { folderName, chooseFolder, bulbSupport, - locked + locked, + voices, + testVoice } = props; return h( @@ -255,6 +265,83 @@ export class SettingsDrawer extends Component { ) ), + h( + 'section', + null, + h('h3', null, 'Voice countdown'), + supportsSpeech + ? h( + Fragment, + null, + h( + 'p', + { class: 'notice' }, + 'Switched on and off with the πŸ”Š button on the home screen.' + ), + h( + Row, + { + label: 'Start counting at', + hint: 'Capped at one second less than the interval, so it never talks over the previous frame.' + }, + h('input', { + type: 'number', + min: '1', + max: '30', + step: '1', + value: prefs.voiceCountFrom, + onChange: e => + setPref({ + voiceCountFrom: Math.min( + 30, + Math.max(1, e.currentTarget.valueAsNumber || 1) + ) + }) + }), + h('span', { class: 'unit' }, 'sec') + ), + h( + Row, + { label: 'Announce frame number' }, + h('input', { + type: 'checkbox', + checked: prefs.voiceAnnounceFrames, + onChange: e => + setPref({ voiceAnnounceFrames: e.currentTarget.checked }) + }) + ), + h( + Row, + { label: 'Voice' }, + h( + 'select', + { + value: prefs.voiceURI, + onChange: e => setPref({ voiceURI: e.currentTarget.value }) + }, + h('option', { value: '' }, 'Browser default'), + voices.map(v => + h('option', { key: v.voiceURI, value: v.voiceURI }, `${v.name} (${v.lang})`) + ) + ) + ), + h( + Row, + { label: 'Test' }, + h( + 'button', + { type: 'button', class: 'secondary', onclick: testVoice }, + 'πŸ”Š Say β€œthree, two, one”' + ) + ) + ) + : h( + 'p', + { class: 'notice' }, + 'This browser has no speech synthesis, so the voice countdown is unavailable.' + ) + ), + h( 'section', null, diff --git a/examples/preact/tsconfig.json b/examples/preact/tsconfig.json index 463c328..6cd74fe 100644 --- a/examples/preact/tsconfig.json +++ b/examples/preact/tsconfig.json @@ -1,4 +1,7 @@ { + // dist/ is a copy of these same files assembled for deploy; checking it too + // just reports every error twice, against stale copies. + "exclude": ["dist", "node_modules"], "compilerOptions": { "checkJs": true, "target": "ESNext", diff --git a/examples/preact/voice.js b/examples/preact/voice.js new file mode 100644 index 0000000..73cec06 --- /dev/null +++ b/examples/preact/voice.js @@ -0,0 +1,157 @@ +/* + * Copyright 2021 Google LLC + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA + */ + +/** @typedef {import('./intervalometer.js').SequenceState} SequenceState */ + +export const supportsSpeech = 'speechSynthesis' in globalThis; + +/** + * Speaks the run out loud: a countdown into each frame, and the odd status + * announcement. Useful when you're in front of the camera rather than at the + * laptop, or holding still for a long exposure. + * + * Driven from the intervalometer's state updates (~5/s) rather than its own + * timer, so it can't drift away from what the sequence is actually doing. Each + * second is spoken at most once, tracked by the second it belongs to. + */ +export class Narrator { + enabled = false; + /** Seconds before a frame to start counting. */ + countFrom = 5; + announceFrames = false; + /** Empty means the browser's default voice. */ + voiceURI = ''; + + /** @type {number | null} */ + #lastSecond = null; + #lastNextAt = 0; + /** @type {string} */ + #lastPhase = 'idle'; + /** @type {SpeechSynthesisVoice[]} */ + #voices = []; + + constructor() { + if (!supportsSpeech) return; + this.#loadVoices(); + // Voices arrive asynchronously, and on some platforms only after this fires. + speechSynthesis.onvoiceschanged = () => this.#loadVoices(); + } + + #loadVoices() { + this.#voices = speechSynthesis.getVoices(); + } + + get voices() { + return this.#voices; + } + + /** + * @param {string} text + * @param {{ interrupt?: boolean }} [options] + */ + say(text, { interrupt = false } = {}) { + if (!supportsSpeech || !this.enabled) return; + // A countdown that queues up behind a stale announcement is worse than + // silence, so anything time-critical clears the queue first. + if (interrupt) speechSynthesis.cancel(); + let utterance = new SpeechSynthesisUtterance(text); + let voice = this.#voices.find(v => v.voiceURI === this.voiceURI); + if (voice) utterance.voice = voice; + // Slightly quick, so a spoken "three" lands nearer the second it names. + utterance.rate = 1.1; + speechSynthesis.speak(utterance); + } + + cancel() { + if (supportsSpeech) speechSynthesis.cancel(); + } + + /** Forget what's been spoken, e.g. when a new sequence starts. */ + reset() { + this.#lastSecond = null; + this.#lastNextAt = 0; + this.#lastPhase = 'idle'; + this.cancel(); + } + + /** + * Called on every sequence state update. + * @param {SequenceState} state + */ + update(state) { + if (!this.enabled || !supportsSpeech) { + this.#lastPhase = state.phase; + return; + } + + // A new target time means a new frame to count into. + if (state.nextAt !== this.#lastNextAt) { + this.#lastNextAt = state.nextAt; + this.#lastSecond = null; + } + + if (state.phase === 'waiting' || state.phase === 'delay') { + // Never start counting from further out than the gap actually is - with a + // 3s interval and a count of 5 you'd otherwise talk over the last frame. + let gapSeconds = + state.phase === 'delay' + ? Infinity + : Math.round(state.intervalMs / 1000) - 1; + let limit = Math.max(0, Math.min(this.countFrom, gapSeconds)); + let remaining = Math.ceil((state.nextAt - Date.now()) / 1000); + if (remaining >= 1 && remaining <= limit && remaining !== this.#lastSecond) { + this.#lastSecond = remaining; + this.say(String(remaining)); + } + } + + if (state.phase !== this.#lastPhase) { + this.#announcePhase(state); + this.#lastPhase = state.phase; + } + } + + /** @param {SequenceState} state */ + #announcePhase(state) { + switch (state.phase) { + case 'capturing': + if (this.announceFrames) { + this.say( + state.total + ? `Frame ${state.taken + 1} of ${state.total}` + : `Frame ${state.taken + 1}` + ); + } + break; + case 'done': + this.say( + `Sequence complete. ${state.taken} frame${ + state.taken === 1 ? '' : 's' + }.`, + { interrupt: true } + ); + break; + case 'stopped': + this.say('Sequence stopped.', { interrupt: true }); + break; + case 'failed': + this.say('Sequence failed.', { interrupt: true }); + break; + } + } +}