"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.
248 lines
7.8 KiB
TypeScript
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,
|
|
};
|
|
}
|