freezmo/lib/podcast-speech.ts
Martin Dvorak 5d82612e2a Podcast player: pause no longer kills the system voice
"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.
2026-10-16 17:25:12 +00:00

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,
};
}