Files
web-dslr/examples/preact/voice.js
Jon 55958ae206
Some checks failed
CI / build-and-deploy (push) Has been cancelled
Add a theme toggle, and fix the voice picker showing no voices
Theme toggle sits next to the settings icon. The choice starts as 'system' and
follows the OS until you press it, after which it's explicit and persisted.

An inline script in <head> stamps the resolved theme on <html> before the
first paint - resolving it from JS after load means a visible flash of dark on
the way to light. Because that attribute is always present, the stylesheet
drops its prefers-color-scheme query entirely rather than having a media query
and an explicit override fighting over the same tokens.

The voice picker was effectively empty: Chrome reports zero voices
synchronously and only fills the list when voiceschanged fires, and while the
narrator did reload them, nothing told preact to re-render - so the dropdown
kept whatever existed at construction, which was nothing. It now notifies, and
the list arrives (181 voices here).

That many voices needs shape, so they're sorted with your own language first
and offline voices ahead of network ones, then grouped into optgroups by
language. Network voices are marked as such since they're useless offline,
which for an app built to work in a field matters.

Speed and pitch are adjustable too, both fed through to every utterance.

The settings button gains a class of its own: the header now has two icon
buttons, so identifying it by .icon-button alone hits the theme toggle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QK7PKpRVoy69Fpa32R8abb
2026-08-02 20:17:32 +01:00

185 lines
5.8 KiB
JavaScript

/*
* 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 = '';
rate = 1.1;
pitch = 1;
/**
* Called when the voice list changes, so the UI can re-render. Chrome
* reports zero voices synchronously and only fills the list later, so
* without this the picker is stuck on whatever existed at construction.
* @type {(() => void) | undefined}
*/
onVoicesChanged;
/** @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();
this.onVoicesChanged?.();
};
}
/**
* Chrome hands back ~180 voices in no useful order. Sort them so the ones
* you'd actually pick are near the top: your own language first, then
* offline voices ahead of network ones - the latter are useless in the field.
*/
#loadVoices() {
let primary = (navigator.language || 'en').split('-')[0].toLowerCase();
let rank = v => (v.lang.toLowerCase().startsWith(primary) ? 0 : 1);
this.#voices = speechSynthesis.getVoices().slice().sort(
(a, b) =>
rank(a) - rank(b) ||
a.lang.localeCompare(b.lang) ||
Number(b.localService) - Number(a.localService) ||
a.name.localeCompare(b.name)
);
}
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;
// Default is slightly quick, so a spoken "three" lands nearer the second
// it names.
utterance.rate = this.rate;
utterance.pitch = this.pitch;
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;
}
}
}