"The tts dispatcher thing and the text highlight is completely out of sync on desktop." Firefox + speech-dispatcher (like iOS) reports no onboundary events, so the highlight rode a pure estimate - and a 130-word section drifts seconds away from an engine that speaks at its own pace. - On platforms without boundaries the system engine now speaks one sentence at a time, and every finished utterance re-anchors the clock: measured duration vs estimate, smoothed (half old, half new, clamped to 0.25-4x). Drift cannot accumulate past a sentence. - The per-word estimate is punctuation-aware (full stop ~260ms, comma ~130ms). - Once a boundary does arrive (Chrome), the rest of the section goes back to a single utterance with the exact clock - the accurate path is unchanged. - When a boundary lands mid-section, the remainder is spoken as one utterance (it used to skip to the next section). podcast-test's engine stub finishes utterances now and pins the whole thing: one sentence first, the next following on its own, the highlight inside the spoken text, a boundary collapsing the remainder, the highlight advancing. 53 checks. Version 0.7.10, SW v102.
379 lines
13 KiB
TypeScript
379 lines
13 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). Punctuation is where
|
|
* these engines actually spend their time (sentences stop, commas slow). */
|
|
function wordDuration(text: string): number {
|
|
const letters = Math.max(1, text.replace(/[^\p{L}\p{N}]/gu, "").length);
|
|
let ms = 80 + 42 * letters;
|
|
if (/[.!?…]["')\]]*$/.test(text)) ms += 260;
|
|
else if (/[,;:]["')\]]*$/.test(text)) ms += 130;
|
|
return ms;
|
|
}
|
|
|
|
/** One sentence of a section, in word indices (inclusive). */
|
|
type SentenceRange = { from: number; to: number };
|
|
|
|
/** Sentence spans for a section's words, split on end punctuation. */
|
|
function sentenceRanges(sectionTokens: Token[]): SentenceRange[] {
|
|
const ranges: SentenceRange[] = [];
|
|
let from = 0;
|
|
sectionTokens.forEach((token, index) => {
|
|
const endsSentence = /[.!?…]["')\]]*$/.test(token.text);
|
|
const last = index === sectionTokens.length - 1;
|
|
if (endsSentence || last) {
|
|
ranges.push({ from, to: index });
|
|
from = index + 1;
|
|
}
|
|
});
|
|
return ranges.length > 0 ? ranges : [{ from: 0, to: Math.max(0, sectionTokens.length - 1) }];
|
|
}
|
|
|
|
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;
|
|
/** When the current utterance actually began (calibration). */
|
|
let startedAt = 0;
|
|
/** A breath between sentences: narration needs it, and it keeps the
|
|
* boundary pops of the system engines down. */
|
|
const SENTENCE_BEAT_MS = 90;
|
|
/**
|
|
* Measured ÷ estimated speech time, learned from every finished utterance.
|
|
* Engines without boundary events are estimated word by word; without this
|
|
* factor the highlight drifts seconds behind on a long section (espeak and
|
|
* speech-dispatcher speak at their own pace, nothing like 150 wpm).
|
|
*/
|
|
let calibration = 1;
|
|
|
|
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 one stretch of a section: `to` is the last word index included.
|
|
* Without boundary events the stretches are sentences (so the clock is
|
|
* re-calibrated every few seconds and the highlight cannot drift away);
|
|
* once boundaries are proven, the rest of the section goes in one go.
|
|
*/
|
|
const speakRange = (sectionIndex: number, fromWord: number, toWord: number) => {
|
|
const section = events.sections[sectionIndex];
|
|
if (!section) {
|
|
position = null;
|
|
finishPass();
|
|
events.onEnd();
|
|
return;
|
|
}
|
|
const sectionTokens = wordsIn(sectionIndex);
|
|
if (sectionTokens.length === 0) {
|
|
speakRange(sectionIndex + 1, 0, 0);
|
|
return;
|
|
}
|
|
const from = Math.min(Math.max(0, fromWord), sectionTokens.length - 1);
|
|
const to = Math.min(Math.max(from, toWord), sectionTokens.length - 1);
|
|
position = { section: sectionIndex, word: from };
|
|
|
|
const base = sectionTokens[from].start;
|
|
const text = normaliseSpoken(section.text.slice(base, sectionTokens[to].end));
|
|
const current = ++generation;
|
|
try {
|
|
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;
|
|
const rate = Math.min(3, Math.max(0.3, events.getRate?.() ?? 1));
|
|
utterance.rate = rate;
|
|
const voice = events.getVoice?.() ?? null;
|
|
if (voice) utterance.voice = voice;
|
|
|
|
/** Estimated time of the words from..to at the current calibration. */
|
|
const estimate = () => {
|
|
let ms = 0;
|
|
for (let i = from; i <= to; i += 1) ms += wordDuration(sectionTokens[i].text);
|
|
return ms / rate;
|
|
};
|
|
|
|
const indexAt = (charIndex: number) => {
|
|
let found = from;
|
|
for (let i = from; i <= to; i += 1) {
|
|
found = i;
|
|
if (sectionTokens[i].end > base + charIndex) break;
|
|
}
|
|
return found;
|
|
};
|
|
|
|
utterance.onboundary = (event) => {
|
|
if (generation !== current) return;
|
|
if (event.name && event.name !== "word") return;
|
|
boundariesWork = true;
|
|
clearTimer();
|
|
const index = indexAt(event.charIndex);
|
|
position = { section: sectionIndex, word: index };
|
|
events.onWord(position);
|
|
};
|
|
|
|
utterance.onstart = () => {
|
|
if (generation !== current) return;
|
|
playing = true;
|
|
startedAt = Date.now();
|
|
events.onPlay?.();
|
|
events.onWord(position ?? { section: sectionIndex, word: from });
|
|
/* The fallback clock: word by word, at the pace this engine has been
|
|
measured to speak. Retired for good once a boundary arrives. */
|
|
if (boundariesWork !== true) {
|
|
let index = from;
|
|
const tick = () => {
|
|
if (generation !== current) return;
|
|
index += 1;
|
|
if (index > to) {
|
|
timer = null;
|
|
return;
|
|
}
|
|
position = { section: sectionIndex, word: index };
|
|
events.onWord(position);
|
|
timer = window.setTimeout(tick, (wordDuration(sectionTokens[index].text) / rate) * calibration);
|
|
};
|
|
timer = window.setTimeout(tick, (wordDuration(sectionTokens[from].text) / rate) * calibration);
|
|
}
|
|
};
|
|
|
|
utterance.onend = () => {
|
|
if (generation !== current) return;
|
|
clearTimer();
|
|
/* Learn this engine's real pace from what just played. */
|
|
if (startedAt > 0) {
|
|
const measured = Date.now() - startedAt;
|
|
const expected = estimate();
|
|
if (measured > 400 && expected > 200) {
|
|
const ratio = Math.min(4, Math.max(0.25, measured / expected));
|
|
calibration = calibration * 0.5 + ratio * 0.5;
|
|
}
|
|
startedAt = 0;
|
|
}
|
|
/* An utterance that ended without a single boundary means the browser
|
|
does not report them: sentence mode stays. */
|
|
if (boundariesWork === null) boundariesWork = false;
|
|
|
|
const lastWord = sectionTokens.length - 1;
|
|
if (to < lastWord) {
|
|
/* There is more of this section to speak. With boundaries proven the
|
|
rest goes in one utterance (the exact clock handles it); without
|
|
them it is the next sentence. */
|
|
const next = boundariesWork === true
|
|
? { from: to + 1, to: lastWord }
|
|
: (() => {
|
|
const ranges = sentenceRanges(sectionTokens);
|
|
const found = ranges.find((range) => range.from > to);
|
|
return found ?? { from: to + 1, to: lastWord };
|
|
})();
|
|
window.setTimeout(() => {
|
|
if (generation !== current) return;
|
|
speakRange(sectionIndex, next.from, next.to);
|
|
}, SENTENCE_BEAT_MS);
|
|
return;
|
|
}
|
|
if (sectionIndex + 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;
|
|
speakRange(sectionIndex + 1, 0, Number.MAX_SAFE_INTEGER);
|
|
}, SENTENCE_BEAT_MS);
|
|
};
|
|
|
|
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) {
|
|
if (!speechAvailable()) {
|
|
events.onError?.("unsupported");
|
|
return;
|
|
}
|
|
const target = from ?? position ?? { section: 0, word: 0 };
|
|
const sectionTokens = wordsIn(target.section);
|
|
if (boundariesWork === true || sectionTokens.length === 0) {
|
|
/* Boundaries are exact: the rest of the section in one utterance. */
|
|
speakRange(target.section, target.word, Number.MAX_SAFE_INTEGER);
|
|
return;
|
|
}
|
|
/* No boundaries yet: speak the sentence the listener is in. */
|
|
const ranges = sentenceRanges(sectionTokens);
|
|
const range = ranges.find((entry) => entry.to >= target.word) ?? ranges[ranges.length - 1];
|
|
speakRange(target.section, Math.max(range.from, Math.min(target.word, range.to)), range.to);
|
|
},
|
|
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,
|
|
};
|
|
}
|