freezmo/lib/podcast-audio.ts
Martin Dvorak 8e1cd0855d 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.
2026-10-16 16:34:46 +00:00

283 lines
8.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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();
},
};
}