// Strip a trailing pop / plosive off a clip. // // A lot of "unclean" ums are a perfectly good vowel followed by a lip smack, a // plosive burst from the next word, or a mic pop. Those are acoustically very // different from the vowel: the vowel is VOICED (periodic, low zero-crossing // rate, energy concentrated low) while the burst is broadband and aperiodic. // // So rather than cutting on energy alone -- which would also chop the natural // decay of the vowel -- find where voicing ends and only cut if what follows // actually looks like a burst. import { yinFrame } from "./pitch.mjs"; // energy + zero-crossing rate on a short frame function frameStats(x, i, n) { let e = 0, zc = 0; for (let k = 0; k < n && i + k < x.length; k += 1) { e += x[i + k] * x[i + k]; if (k > 0 && (x[i + k] >= 0) !== (x[i + k - 1] >= 0)) zc += 1; } return { rms: Math.sqrt(e / n), zcr: zc / n }; } /** * @returns {{end:number, cutMs:number, why:string}} a possibly-earlier end time */ export function stripTrailingPop(x, sr, s, e, opts = {}) { const HOP = Math.round(sr * 0.002); // 2ms const WIN = Math.round(sr * 0.008); // 8ms const MIN_KEEP = opts.minKeep ?? 0.14; const words = opts.words ?? []; const a0 = Math.max(0, Math.round(s * sr)); const b0 = Math.min(x.length, Math.round(e * sr)); if (b0 - a0 < MIN_KEEP * sr) return { end: e, cutMs: 0, why: "too short to trim" }; // 0) The next word's own timestamp, when parakeet gives us one. This is the // only rule that catches a VOICED intruder -- the "a" of "and", the vowel of // "the" -- which looks like more vowel to every acoustic test below. for (const w of words) { if (w.start > s + MIN_KEEP && w.start < e - 0.005) { const cand = w.start - 0.02; if (cand - s >= MIN_KEEP) { const ms = Math.round((e - cand) * 1000); if (ms >= 8) return { end: +cand.toFixed(4), cutMs: ms, why: `next word "${w.w ?? "?"}" starts here` }; } } } const frames = []; for (let i = a0; i + WIN <= b0; i += HOP) frames.push({ i, ...frameStats(x, i, WIN) }); if (frames.length < 8) return { end: e, cutMs: 0, why: "too few frames" }; const maxRms = Math.max(...frames.map((f) => f.rms)); if (maxRms <= 0) return { end: e, cutMs: 0, why: "silent" }; // typical ZCR of the loud (vowel) part -- the burst is judged against this const loud = frames.filter((f) => f.rms > 0.5 * maxRms); if (!loud.length) return { end: e, cutMs: 0, why: "no vowel body" }; const vowelZcr = loud.reduce((t, f) => t + f.zcr, 0) / loud.length; // Walk back from the end: the burst is the trailing run whose ZCR is well // above the vowel's, or which sits after a clear energy trough. const W = 2048; const voiced = (i) => { if (i + W >= x.length) return false; const q = yinFrame(x, i, W, sr, 55, 300); return q.conf >= 0.45 && q.f0 > 0; }; let cut = -1, why = ""; // 1) trailing high-ZCR run (a smack or plosive is much noisier than a vowel) let k = frames.length - 1; while (k > 2 && frames[k].zcr > vowelZcr * 1.8 && frames[k].rms > 0.04 * maxRms) k -= 1; if (k < frames.length - 2) { cut = k; why = "trailing noise burst"; } // 2) energy trough followed by a fresh attack in the last third const start3 = Math.floor(frames.length * 0.55); for (let j = start3; j < frames.length - 3; j += 1) { const trough = frames[j].rms; if (trough > 0.22 * maxRms) continue; let peak = 0; for (let m = j + 1; m < frames.length; m += 1) peak = Math.max(peak, frames[m].rms); if (peak > trough * 3 && peak > 0.18 * maxRms) { // it comes back up: a new sound if (cut < 0 || j < cut) { cut = j; why = "re-attack after a dip"; } break; } } // 3) End of voicing. The two rules above only fire on an obvious burst, which // missed most of the pile (16 of 81). A "slight" pop often has neither a deep // energy trough nor a dramatic ZCR jump -- but it is still not the vowel. The // vowel is periodic at a stable pitch, so find the last frame that is voiced // AT THE CLIP'S OWN PITCH and treat everything past it as tail. if (cut < 0) { const step = Math.round(sr * 0.005); const f0s = []; for (let i = a0; i + W < b0; i += step) { const q = yinFrame(x, i, W, sr, 55, 300); f0s.push({ i, f0: q.conf >= 0.5 ? q.f0 : 0 }); } const ok = f0s.filter((f) => f.f0 > 0).map((f) => f.f0).sort((u, v) => u - v); if (ok.length >= 3) { const mid = ok[ok.length >> 1]; let last = -1; for (const f of f0s) { if (f.f0 > 0 && Math.abs(1200 * Math.log2(f.f0 / mid)) <= 120) last = f.i; } // +W because a YIN frame starting at `last` covers W samples of vowel if (last > 0 && (last + W) < b0 - Math.round(sr * 0.012)) { const idx3 = Math.min(b0, last + W); const cand = idx3 / sr; if (cand - s >= MIN_KEEP) { const cutMs3 = Math.round((e - cand) * 1000); if (cutMs3 >= 8) return { end: +cand.toFixed(4), cutMs: cutMs3, why: "unvoiced tail" }; } } } } // 4) Timbre change. A new articulation shifts the balance of high to low // frequency energy even when it is voiced and at the same pitch, so a sustained // late jump in that ratio marks where the um stopped and something else began. if (cut < 0) { const ratio = frames.map((f) => { const i = f.i; let hi = 0, lo = 0; for (let k = 1; k < WIN && i + k < x.length; k += 1) { const d = x[i + k] - x[i + k - 1]; // crude high-pass hi += d * d; lo += x[i + k] * x[i + k]; } return lo > 0 ? hi / lo : 0; }); const body = ratio.slice(0, Math.floor(ratio.length * 0.6)).filter((v) => v > 0).sort((a, b) => a - b); if (body.length >= 4) { const med = body[body.length >> 1]; const spread = body[Math.floor(body.length * 0.9)] - body[Math.floor(body.length * 0.1)] || med * 0.5; const from = Math.floor(frames.length * 0.5); for (let j = from; j < frames.length - 2; j += 1) { // sustained, not a single-frame blip if (ratio[j] > med + 3 * spread && ratio[j + 1] > med + 2 * spread && ratio[j + 2] > med + 2 * spread) { const cand = frames[j].i / sr; if (cand - s >= MIN_KEEP) { const ms = Math.round((e - cand) * 1000); if (ms >= 8) return { end: +cand.toFixed(4), cutMs: ms, why: "timbre changes (new sound starts)" }; } break; } } } } if (cut < 0) return { end: e, cutMs: 0, why: "nothing to strip" }; // Never cut into voiced audio: back off to the last voiced frame. let idx = frames[cut].i; let guard = 0; while (idx > a0 && !voiced(idx - W) && guard < 200) { idx -= HOP; guard += 1; } const newEnd = Math.max(s + MIN_KEEP, idx / sr); const cutMs = Math.round((e - newEnd) * 1000); if (cutMs < 8) return { end: e, cutMs: 0, why: "trim too small to matter" }; if (newEnd - s < MIN_KEEP) return { end: e, cutMs: 0, why: "would leave too little" }; return { end: +newEnd.toFixed(4), cutMs, why }; }