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.
283 lines
8.8 KiB
TypeScript
283 lines
8.8 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* The provider-voice playback engine: one section at a time is turned into
|
||
* audio by the browser's provider key (/api/tts) and played through an
|
||
* <audio> element.
|
||
*
|
||
* Word highlighting has no boundary events here, but it does not need to
|
||
* guess: the clip's real duration is known, so each word gets a slice of it
|
||
* proportional to its spoken length. That keeps the highlight honest from the
|
||
* first word to the last, and it makes pause/seek real audio operations —
|
||
* `currentTime` is the clock.
|
||
*
|
||
* Sections are cached per session (and the next one is fetched while the
|
||
* current plays), so listening to an episode twice costs one generation per
|
||
* section, not one per replay.
|
||
*/
|
||
|
||
import { synthesizeSpeech } from "./client-api";
|
||
import { tokenizeSections, type PodcastVoice } from "./podcast-speech";
|
||
import type { PodcastSection } from "./schemas";
|
||
|
||
export type PodcastAudioEvents = {
|
||
sections: PodcastSection[];
|
||
providerId: string;
|
||
voice: string;
|
||
model?: string;
|
||
/** Initial playback speed (0.5–2); `setRate` changes it while playing. */
|
||
rate?: number;
|
||
onWord: (position: PodcastVoice) => void;
|
||
onPlay?: () => void;
|
||
onPause?: () => void;
|
||
onEnd: () => void;
|
||
onError: (message: string) => void;
|
||
/** True while a section's voice is being generated. */
|
||
onBusy?: (busy: boolean) => void;
|
||
};
|
||
|
||
type Clip = { url: string; duration: number; type: string };
|
||
|
||
export type PodcastAudio = {
|
||
play: (from?: PodcastVoice) => void;
|
||
pause: () => void;
|
||
stop: () => void;
|
||
playing: () => boolean;
|
||
position: () => PodcastVoice | null;
|
||
/** Warm the cache for a section without playing it. */
|
||
prefetch: (index: number) => void;
|
||
/** Change the playback speed without losing the generated clips. */
|
||
setRate: (rate: number) => void;
|
||
/** Drop the element and the cached clips (the player unmounted). */
|
||
dispose: () => void;
|
||
};
|
||
|
||
export function createPodcastAudio(events: PodcastAudioEvents): PodcastAudio {
|
||
const tokens = tokenizeSections(events.sections);
|
||
const clips = new Map<number, Clip>();
|
||
const loading = new Map<number, Promise<Clip>>();
|
||
const audio = new Audio();
|
||
audio.preload = "auto";
|
||
/* In the document (hidden): some engines only start audio for attached
|
||
elements, and it keeps the element inspectable for debugging. */
|
||
audio.setAttribute("data-podcast-audio", "1");
|
||
audio.style.display = "none";
|
||
if (typeof document !== "undefined") document.body.appendChild(audio);
|
||
|
||
let playing = false;
|
||
let position: PodcastVoice | null = null;
|
||
let generation = 0;
|
||
let frame = 0;
|
||
let currentSection = 0;
|
||
let rate = Math.min(2, Math.max(0.5, events.rate ?? 1));
|
||
audio.playbackRate = rate;
|
||
|
||
const wordsOf = (section: number) => tokens.filter((token) => token.section === section);
|
||
|
||
/** Each word's start time inside the clip, by its share of the text. */
|
||
const startsFor = (section: number, duration: number): number[] => {
|
||
const words = wordsOf(section);
|
||
const weights = words.map((word) => Math.max(1, word.text.replace(/[^\p{L}\p{N}]/gu, "").length));
|
||
const total = weights.reduce((sum, weight) => sum + weight, 0) || 1;
|
||
const starts: number[] = [];
|
||
let cursor = 0;
|
||
for (const weight of weights) {
|
||
starts.push((cursor / total) * duration);
|
||
cursor += weight;
|
||
}
|
||
return starts;
|
||
};
|
||
|
||
const load = (index: number): Promise<Clip> => {
|
||
const cached = clips.get(index);
|
||
if (cached) return Promise.resolve(cached);
|
||
const pending = loading.get(index);
|
||
if (pending) return pending;
|
||
const section = events.sections[index];
|
||
if (!section) return Promise.reject(new Error("no-section"));
|
||
events.onBusy?.(true);
|
||
const promise = synthesizeSpeech(
|
||
{
|
||
text: `${section.heading ? `${section.heading}. ` : ""}${section.text}`,
|
||
voice: events.voice,
|
||
model: events.model,
|
||
providerId: events.providerId,
|
||
},
|
||
)
|
||
.then(async ({ url, type }) => {
|
||
const duration = await measure(url);
|
||
const clip = { url, duration, type };
|
||
clips.set(index, clip);
|
||
loading.delete(index);
|
||
events.onBusy?.(false);
|
||
return clip;
|
||
})
|
||
.catch((error: unknown) => {
|
||
loading.delete(index);
|
||
events.onBusy?.(false);
|
||
throw error;
|
||
});
|
||
loading.set(index, promise);
|
||
return promise;
|
||
};
|
||
|
||
/** The clip's real length, read before playing so the clock can be built. */
|
||
const measure = (url: string): Promise<number> =>
|
||
new Promise((resolve) => {
|
||
const probe = new Audio();
|
||
probe.preload = "metadata";
|
||
const done = () => {
|
||
const duration = Number.isFinite(probe.duration) && probe.duration > 0 ? probe.duration : 0;
|
||
resolve(duration);
|
||
};
|
||
probe.onloadedmetadata = done;
|
||
probe.onerror = () => resolve(0);
|
||
probe.src = url;
|
||
/* Some engines never fire the event for blob URLs without a nudge. */
|
||
window.setTimeout(done, 2500);
|
||
});
|
||
|
||
const stopClock = () => {
|
||
if (frame) cancelAnimationFrame(frame);
|
||
frame = 0;
|
||
};
|
||
|
||
const startClock = () => {
|
||
const mine = generation;
|
||
const section = currentSection;
|
||
const clip = clips.get(section);
|
||
if (!clip) return;
|
||
const starts = startsFor(section, clip.duration);
|
||
const tick = () => {
|
||
if (generation !== mine) return;
|
||
const at = audio.currentTime;
|
||
/* Last word whose start is behind the cursor. */
|
||
let index = 0;
|
||
for (let i = 0; i < starts.length; i += 1) {
|
||
if (starts[i] <= at) index = i;
|
||
else break;
|
||
}
|
||
const next = { section, word: index };
|
||
if (!position || position.section !== section || position.word !== index) {
|
||
position = next;
|
||
events.onWord(next);
|
||
}
|
||
if (clip.duration > 0 && at >= clip.duration - 0.05) {
|
||
playFrom({ section: section + 1, word: 0 });
|
||
return;
|
||
}
|
||
frame = requestAnimationFrame(tick);
|
||
};
|
||
frame = requestAnimationFrame(tick);
|
||
};
|
||
|
||
const playFrom = (target: PodcastVoice) => {
|
||
const section = events.sections[target.section];
|
||
if (!section) {
|
||
playing = false;
|
||
position = null;
|
||
events.onPause?.();
|
||
events.onEnd();
|
||
return;
|
||
}
|
||
const mine = ++generation;
|
||
stopClock();
|
||
playing = false;
|
||
void load(target.section)
|
||
.then((clip) => {
|
||
if (generation !== mine) return;
|
||
const words = wordsOf(target.section);
|
||
const starts = startsFor(target.section, clip.duration);
|
||
const word = Math.min(Math.max(0, target.word), Math.max(0, words.length - 1));
|
||
currentSection = target.section;
|
||
position = { section: target.section, word };
|
||
events.onWord(position);
|
||
if (audio.src !== clip.url) audio.src = clip.url;
|
||
audio.playbackRate = rate;
|
||
try {
|
||
audio.currentTime = starts[word] ?? 0;
|
||
} catch {
|
||
/* not seekable yet: start from the top */
|
||
}
|
||
audio
|
||
.play()
|
||
.then(() => {
|
||
if (generation !== mine) return;
|
||
playing = true;
|
||
events.onPlay?.();
|
||
startClock();
|
||
/* The next part is generated while this one plays. */
|
||
if (events.sections[target.section + 1]) void load(target.section + 1).catch(() => {});
|
||
})
|
||
.catch(() => {
|
||
if (generation !== mine) return;
|
||
playing = false;
|
||
events.onError?.("blocked");
|
||
});
|
||
})
|
||
.catch((error: unknown) => {
|
||
if (generation !== mine) return;
|
||
const message = error instanceof Error ? error.message : "error";
|
||
events.onError?.(message);
|
||
});
|
||
};
|
||
|
||
audio.onended = () => {
|
||
if (currentSection + 1 >= events.sections.length) {
|
||
playing = false;
|
||
position = null;
|
||
events.onPause?.();
|
||
events.onEnd();
|
||
return;
|
||
}
|
||
playFrom({ section: currentSection + 1, word: 0 });
|
||
};
|
||
|
||
audio.onerror = () => {
|
||
playing = false;
|
||
events.onError?.("error");
|
||
};
|
||
|
||
return {
|
||
play(from) {
|
||
playFrom(from ?? position ?? { section: 0, word: 0 });
|
||
},
|
||
pause() {
|
||
generation += 1;
|
||
stopClock();
|
||
try {
|
||
audio.pause();
|
||
} catch {
|
||
/* already gone */
|
||
}
|
||
playing = false;
|
||
events.onPause?.();
|
||
},
|
||
stop() {
|
||
this.pause();
|
||
position = null;
|
||
},
|
||
playing: () => playing,
|
||
position: () => position,
|
||
prefetch(index) {
|
||
if (events.sections[index]) void load(index).catch(() => {});
|
||
},
|
||
setRate(next) {
|
||
rate = Math.min(2, Math.max(0.5, next));
|
||
audio.playbackRate = rate;
|
||
},
|
||
dispose() {
|
||
generation += 1;
|
||
stopClock();
|
||
try {
|
||
audio.pause();
|
||
audio.removeAttribute("src");
|
||
audio.remove();
|
||
} catch {
|
||
/* already gone */
|
||
}
|
||
for (const clip of clips.values()) URL.revokeObjectURL(clip.url);
|
||
clips.clear();
|
||
},
|
||
};
|
||
}
|