Podcast voices: natural provider TTS, and system-voice hygiene
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.
This commit is contained in:
parent
9882d775e7
commit
8e1cd0855d
13 changed files with 997 additions and 63 deletions
283
lib/podcast-audio.ts
Normal file
283
lib/podcast-audio.ts
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue