Two reports, one root: the platform's speech engine. "Loud cracking at the end of every sentence" is the browser/OS TTS popping between its internal sentence chunks (an idle cancel makes it worse). Hygiene for that path: never cancel when nothing is pending, normalise the spoken text (control characters, ellipses, dashes), and a 140ms beat between sections so speech never runs into the engine's tail. The real fix is a second voice source that does not use the platform engine: - lib/providers carries TTS profiles (OpenAI gpt-4o-mini-tts, Groq playai-tts, any OpenAI-compatible /audio/speech behind the custom provider), male voices first. - /api/tts proxies them with the same guards and the key still in headers. - the picker gains System / Natural, with the provider's voice list. - lib/podcast-audio plays one clip per section through a hidden <audio> element, measures the clip's real duration and gives each word a slice of it proportional to its spoken length - the highlight follows the audio exactly, and pause/seek/chapters become real audio operations. Clips cache per session, the next section generates while the current plays, and a voice change disposes the element and its blobs. Speed applies live (setRate) without dropping the cache. podcast-test stubs /api/tts with a real silent WAV and pins the path: the voice list, the request body, exactly one playing element, and the highlight riding currentTime. 43 checks. tests/lib.mjs launches Chromium with the autoplay policy relaxed (a real device has the listener's gesture). Version 0.7.6, SW v98.
269 lines
8.7 KiB
TypeScript
269 lines
8.7 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;
|
|
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;
|
|
/* 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. */
|
|
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
|
|
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;
|
|
}
|
|
/* 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?.();
|
|
}
|
|
};
|
|
|
|
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,
|
|
};
|
|
}
|