/* * 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; } } }