freezmo/lib/podcast-speech.ts
Martin Dvorak 4ec986da12 Podcast player: calmer line-following scroll, and a voice you can pick
"The scrolling works kinda weirdly" + "maybe a less robotic voice, preferably
male".

Scroll: the transcript now follows by LINE, not by word - words on the same
line never move the page (that was the jitter), a line only scrolls once it
leaves a comfortable band, small corrections are instant rather than animated,
and a wheel/touch/drag on the transcript pauses the following for four seconds
instead of fighting the reader. Chapter jumps and word taps re-centre at once.

Voice: lib/podcast-voices ranks what the platform installed (natural / neural
/ online names up, espeak / SAM / compact down, a male name preferred) and the
player picks the best one automatically; a new picker in the chrome lists
everything in the episode's language, applies it live and remembers the choice
per language. A note points at the system settings when only robotic voices
exist. The engine takes the chosen voice instead of guessing.

podcast-test: 34 checks now (voice ranking, filtered picker, persisted pick,
same-line silence, line following, manual-scroll suspension). Version 0.7.4,
SW v96.
2026-10-16 13:05:57 +00:00

248 lines
7.8 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 };
export type PodcastSpeechEvents = {
sections: PodcastSection[];
locale: string;
rate: number;
/** The chosen voice (lib/podcast-voices); null lets the platform decide. */
voice?: 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;
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;
const text = section.text.slice(from);
const current = ++generation;
try {
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = events.locale;
utterance.rate = events.rate;
if (events.voice) utterance.voice = events.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.rate);
};
timer = window.setTimeout(tick, wordDuration(sectionTokens[word].text) / events.rate);
}
};
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;
}
speakFrom({ section: target.section + 1, word: 0 });
};
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?.();
}
};
window.speechSynthesis.speak(utterance);
} catch {
playing = false;
events.onError?.("error");
}
};
return {
play(from) {
speakFrom(from ?? position ?? { section: 0, word: 0 });
},
pause() {
generation += 1;
clearTimer();
try {
window.speechSynthesis.cancel();
} catch {
/* already gone */
}
playing = false;
events.onPause?.();
},
stop() {
this.pause();
position = null;
},
playing: () => playing,
position: () => position,
words: tokens,
};
}