tts: trim leading silence per chunk via Web Audio decode to skip F5 phantom-phoneme artifacts
This commit is contained in:
parent
075db9154a
commit
7a5f545c00
1 changed files with 56 additions and 3 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue