tts: trim leading silence per chunk via Web Audio decode to skip F5 phantom-phoneme artifacts

This commit is contained in:
russell@unturf.com 2026-05-24 10:32:43 -04:00
parent 075db9154a
commit 7a5f545c00
No known key found for this signature in database

View file

@ -131,10 +131,48 @@ function splitTextIntoChunks(text) {
return chunks;
}
// Shared AudioContext for decoding-only operations (silence detection).
// Lazily created so we don't hit iOS Safari's "must come from gesture" restriction
// unless we actually need it.
let _decodeCtx = null;
function getDecodeCtx() {
if (!_decodeCtx) {
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return null;
_decodeCtx = new Ctx();
}
return _decodeCtx;
}
// Detect where speech actually begins in an audio blob. Scans the first 1.5s of
// decoded PCM for the first sample whose absolute amplitude crosses `threshold`,
// then returns that time minus a 20ms safety margin so we don't clip the first
// phoneme. Returns 0 on any decode failure (degrade gracefully).
async function detectSpeechStart(blob, threshold = 0.05, safetyMs = 20) {
try {
const ctx = getDecodeCtx();
if (!ctx) return 0;
const arrayBuffer = await blob.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0));
const data = audioBuffer.getChannelData(0);
const sampleRate = audioBuffer.sampleRate;
const scanSamples = Math.min(data.length, Math.floor(sampleRate * 1.5));
for (let i = 0; i < scanSamples; i++) {
if (Math.abs(data[i]) > threshold) {
return Math.max(0, i / sampleRate - safetyMs / 1000);
}
}
return 0;
} catch (e) {
console.warn("detectSpeechStart failed:", e);
return 0;
}
}
// Pipelined playlist player: fetches all chunks in parallel, plays each as soon
// as ready, chains via 'ended'. Exposes Audio-compatible API so downstream UI
// (pause/play, download, progress) works unchanged. Bonus: fixes Safari MP3-concat
// bug by playing each chunk as its own <audio> element.
// (pause/play, download, progress) works unchanged. Trims leading silence and
// F5 phantom-phoneme noise per chunk via Web Audio decode + currentTime skip.
class PlaylistAudio {
constructor(chunks, voice, model, rate) {
this._chunks = chunks;
@ -157,11 +195,26 @@ class PlaylistAudio {
try {
const blob = await fetchTTSChunk(text, voice, model);
this._blobs[idx] = blob;
// Detect where actual speech starts so we can skip past F5's leading
// silence (~300ms) and the phantom-phoneme noise burst F5 emits before
// the real first word of each chunk. Without this, users hear "random
// chars" at the start of each chunk's audio.
const leadOffset = await detectSpeechStart(blob);
const audio = new Audio(URL.createObjectURL(blob));
audio.playbackRate = this._rate;
audio.preload = "auto";
if (leadOffset > 0) {
const setStart = () => {
try { audio.currentTime = leadOffset; } catch (_e) {}
};
if (audio.readyState >= 1) setStart();
else audio.addEventListener("loadedmetadata", setStart, { once: true });
}
audio.addEventListener('ended', () => this._advance(idx));
this._audios[idx] = audio;
console.log(`PlaylistAudio: chunk ${idx + 1}/${this._chunks.length} ready`);
console.log(`PlaylistAudio: chunk ${idx + 1}/${this._chunks.length} ready (skip leading ${(leadOffset * 1000).toFixed(0)}ms)`);
if (idx === this._idx && this._wantPlay) {
this._wantPlay = false;
this._playCurrent();