"It only plays once, and when you pause it you can't unpause." WebKit leaves speechSynthesis paused after a cancel(), and every later speak() is queued into that paused state - silently. Fixes: - resume() whenever the synth is paused, before speaking and after our own cancel; - a ~90ms beat before a speak() that follows a cancel (several engines drop it otherwise); - no cancel when nothing is pending. The speech engine also no longer rebuilds on rate or voice changes: both are read live through getters, so a voiceschanged arriving after the first utterance cannot leave a second engine speaking over the first. A rate or voice change restarts the current part from the same word instead (the Web Speech API cannot change either mid-utterance), making the change audible at once; the natural engine applies speed live and resumes across a voice swap. The suite's speech stub now models the quirk (cancel -> paused, speak while paused is dropped and counted) and pins that nothing is ever spoken into a paused engine. 44 checks. Version 0.7.7, SW v99.
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The podcast player's voice: one section at a time, with the spoken word
|
|
* reported back so the transcript can highlight it (the Spotify-lyrics bit).
|
|
*
|
|
* Two clocks drive the highlight. Where the browser fires `onboundary`
|
|
* (desktop Chrome and friends) that is exact — the character index maps onto
|
|
* the word list. Where it does not (iOS Safari, several Android engines) a
|
|
* timer estimates each word from its length and the playback rate, so the
|
|
* highlight still moves at roughly speaking speed. The first boundary seen
|
|
* retires the timer for the rest of the session.
|
|
*
|
|
* Pause/seek are implemented by cancelling and re-speaking from a word: the
|
|
* platform `pause()` is unreliable across browsers, and re-speaking also makes
|
|
* "jump to a chapter" and "resume where you left off" the same operation.
|
|
*/
|
|
|
|
import { speechAvailable } from "./speech";
|
|
import type { PodcastSection } from "./schemas";
|
|
|
|
export type PodcastVoice = { section: number; word: number };
|
|
|
|
/** Tidy text for the speech engine (see the note where it is used). */
|
|
export function normaliseSpoken(raw: string): string {
|
|
return raw
|
|
.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\ufeff]/g, " ")
|
|
.replace(/…/g, ", ")
|
|
.replace(/\s*—\s*/g, " — ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
export type PodcastSpeechEvents = {
|
|
sections: PodcastSection[];
|
|
locale: string;
|
|
/** Read live, so speed and voice changes never rebuild a speaking engine. */
|
|
getRate?: () => number;
|
|
getVoice?: () => SpeechSynthesisVoice | null;
|
|
/** Global word position changed (word = index inside its section). */
|
|
onWord: (position: PodcastVoice) => void;
|
|
/** Playback actually began (after the engine accepted it). */
|
|
onPlay?: () => void;
|
|
onPause?: () => void;
|
|
onEnd: () => void;
|
|
onError?: (message: string) => void;
|
|
};
|
|
|
|
export type Token = {
|
|
section: number;
|
|
/** Index of the word inside its section. */
|
|
index: number;
|
|
/** Index across the whole episode (the transcript's data-word). */
|
|
global: number;
|
|
start: number;
|
|
end: number;
|
|
text: string;
|
|
};
|
|
|
|
/** Split into words, keeping each one's character span inside its section. */
|
|
export function tokenizeSections(sections: PodcastSection[]): Token[] {
|
|
const tokens: Token[] = [];
|
|
sections.forEach((section, sectionIndex) => {
|
|
const text = section.text;
|
|
const pattern = /\S+/g;
|
|
let index = 0;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = pattern.exec(text)) !== null) {
|
|
tokens.push({
|
|
section: sectionIndex,
|
|
index,
|
|
global: tokens.length,
|
|
start: match.index,
|
|
end: match.index + match[0].length,
|
|
text: match[0],
|
|
});
|
|
index += 1;
|
|
}
|
|
});
|
|
return tokens;
|
|
}
|
|
|
|
/** Estimated spoken time of one word at rate 1 (ms). */
|
|
function wordDuration(text: string): number {
|
|
return 95 + 42 * Math.max(1, text.replace(/[^\p{L}\p{N}]/gu, "").length);
|
|
}
|
|
|
|
export type PodcastSpeech = {
|
|
play: (from?: PodcastVoice) => void;
|
|
pause: () => void;
|
|
stop: () => void;
|
|
playing: () => boolean;
|
|
position: () => PodcastVoice | null;
|
|
words: Token[];
|
|
};
|
|
|
|
export function createPodcastSpeech(events: PodcastSpeechEvents): PodcastSpeech {
|
|
const tokens = tokenizeSections(events.sections);
|
|
let playing = false;
|
|
let position: PodcastVoice | null = null;
|
|
let boundariesWork: boolean | null = null;
|
|
let timer: number | null = null;
|
|
/** Cleared by cancel(): stops stale timers/events from a previous utterance. */
|
|
let generation = 0;
|
|
/** A cancel in the last moments: some engines drop a speak() that follows
|
|
* one immediately, so it gets a tick of air. */
|
|
let lastCancelAt = 0;
|
|
|
|
const clearTimer = () => {
|
|
if (timer !== null) {
|
|
window.clearTimeout(timer);
|
|
timer = null;
|
|
}
|
|
};
|
|
|
|
const finishPass = () => {
|
|
playing = false;
|
|
events.onPause?.();
|
|
};
|
|
|
|
const wordsIn = (section: number) => tokens.filter((token) => token.section === section);
|
|
|
|
/** Speak the section from `word` on. Empty word list → the next section. */
|
|
const speakFrom = (target: PodcastVoice) => {
|
|
if (!speechAvailable()) {
|
|
events.onError?.("unsupported");
|
|
return;
|
|
}
|
|
const section = events.sections[target.section];
|
|
if (!section) {
|
|
position = null;
|
|
finishPass();
|
|
events.onEnd();
|
|
return;
|
|
}
|
|
const sectionTokens = wordsIn(target.section);
|
|
if (sectionTokens.length === 0) {
|
|
speakFrom({ section: target.section + 1, word: 0 });
|
|
return;
|
|
}
|
|
const word = Math.min(Math.max(0, target.word), sectionTokens.length - 1);
|
|
position = { section: target.section, word };
|
|
|
|
const from = sectionTokens[word].start;
|
|
/* Spoken text hygiene: engines click on control characters, stray
|
|
double spaces and unicode ellipses, and some pause oddly around them. */
|
|
const text = normaliseSpoken(section.text.slice(from));
|
|
const current = ++generation;
|
|
try {
|
|
/* Only cancel when something is actually pending: an idle cancel makes
|
|
several engines pop. */
|
|
const synth = window.speechSynthesis;
|
|
const cancelNeeded = synth.speaking || synth.pending;
|
|
const cancelledRecently = Date.now() - lastCancelAt < 400;
|
|
if (cancelNeeded) {
|
|
synth.cancel();
|
|
lastCancelAt = Date.now();
|
|
}
|
|
/* WebKit leaves the synthesis paused after a cancel (and sometimes
|
|
pauses it on its own): without this resume the next speak() queues
|
|
silently and the player looks dead after one listen. */
|
|
if (synth.paused) synth.resume();
|
|
const utterance = new SpeechSynthesisUtterance(text);
|
|
utterance.lang = events.locale;
|
|
utterance.rate = Math.min(3, Math.max(0.3, events.getRate?.() ?? 1));
|
|
const voice = events.getVoice?.() ?? null;
|
|
if (voice) utterance.voice = voice;
|
|
|
|
const offsetFor = (charIndex: number) => {
|
|
/* First token whose span ends after the reported character. */
|
|
let found = word;
|
|
for (let i = word; i < sectionTokens.length; i += 1) {
|
|
const token = sectionTokens[i];
|
|
if (token.end > from + charIndex) {
|
|
found = i;
|
|
break;
|
|
}
|
|
found = i;
|
|
}
|
|
return found;
|
|
};
|
|
|
|
utterance.onboundary = (event) => {
|
|
if (generation !== current) return;
|
|
if (event.name && event.name !== "word") return;
|
|
boundariesWork = true;
|
|
clearTimer();
|
|
const index = offsetFor(event.charIndex);
|
|
position = { section: target.section, word: index };
|
|
events.onWord(position);
|
|
};
|
|
|
|
utterance.onstart = () => {
|
|
if (generation !== current) return;
|
|
playing = true;
|
|
events.onPlay?.();
|
|
events.onWord(position ?? { section: target.section, word });
|
|
/* The fallback clock: only while this browser has never reported a
|
|
* boundary. Each tick schedules the next word by its length. */
|
|
if (boundariesWork !== true) {
|
|
let index = word;
|
|
const tick = () => {
|
|
if (generation !== current) return;
|
|
index += 1;
|
|
if (index >= sectionTokens.length) {
|
|
/* Let onend take over; a long word list can outlast the audio
|
|
* by a hair, and stopping the tick avoids double-advancing. */
|
|
timer = null;
|
|
return;
|
|
}
|
|
position = { section: target.section, word: index };
|
|
events.onWord(position);
|
|
timer = window.setTimeout(tick, wordDuration(sectionTokens[index].text) / (events.getRate?.() ?? 1));
|
|
};
|
|
timer = window.setTimeout(tick, wordDuration(sectionTokens[word].text) / (events.getRate?.() ?? 1));
|
|
}
|
|
};
|
|
|
|
utterance.onend = () => {
|
|
if (generation !== current) return;
|
|
clearTimer();
|
|
/* An utterance that ended without a single boundary means the browser
|
|
does not report them: the fallback clock was the right call. */
|
|
if (boundariesWork === null) boundariesWork = false;
|
|
if (target.section + 1 >= events.sections.length) {
|
|
position = null;
|
|
finishPass();
|
|
events.onEnd();
|
|
return;
|
|
}
|
|
/* A short beat between parts: speaking into the engine's tail is
|
|
what makes the boundary pop on several platforms. */
|
|
window.setTimeout(() => {
|
|
if (generation !== current) return;
|
|
speakFrom({ section: target.section + 1, word: 0 });
|
|
}, 140);
|
|
};
|
|
|
|
utterance.onerror = (event) => {
|
|
if (generation !== current) return;
|
|
clearTimer();
|
|
playing = false;
|
|
/* "interrupted"/"canceled" are our own stops, not failures. */
|
|
const reason = (event as SpeechSynthesisErrorEvent).error;
|
|
if (reason !== "interrupted" && reason !== "canceled") {
|
|
events.onError?.(String(reason ?? "error"));
|
|
events.onPause?.();
|
|
}
|
|
};
|
|
|
|
const sayIt = () => {
|
|
if (generation !== current) return;
|
|
try {
|
|
window.speechSynthesis.speak(utterance);
|
|
} catch {
|
|
playing = false;
|
|
events.onError?.("error");
|
|
}
|
|
};
|
|
/* A speak() that follows a cancel immediately is dropped by several
|
|
engines (Android Chrome, WebKit); a short beat avoids it. */
|
|
if (cancelNeeded || cancelledRecently) window.setTimeout(sayIt, 90);
|
|
else sayIt();
|
|
} catch {
|
|
playing = false;
|
|
events.onError?.("error");
|
|
}
|
|
};
|
|
|
|
return {
|
|
play(from) {
|
|
speakFrom(from ?? position ?? { section: 0, word: 0 });
|
|
},
|
|
pause() {
|
|
generation += 1;
|
|
clearTimer();
|
|
try {
|
|
const synth = window.speechSynthesis;
|
|
if (synth.speaking || synth.pending) {
|
|
synth.cancel();
|
|
lastCancelAt = Date.now();
|
|
}
|
|
/* Cancel leaves some engines (WebKit especially) paused; clear that
|
|
here so the next play is not a silent no-op. */
|
|
if (synth.paused) synth.resume();
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
playing = false;
|
|
events.onPause?.();
|
|
},
|
|
stop() {
|
|
this.pause();
|
|
position = null;
|
|
},
|
|
playing: () => playing,
|
|
position: () => position,
|
|
words: tokens,
|
|
};
|
|
}
|