// Clipwhit moment finder: pure functions (browser + Node tests). Turns a timestamped transcript and an audio // loudness envelope into ranked, non-overlapping short-clip candidates with the reasons each one scored. // © 2026 CyberMax. All rights reserved. export const STOP = new Set(('a an and are as at be but by for from has have he her his i if in into is it its just like me my ' + 'not of on or our out so that the their them then there they this to up us was we were what when which who will with you your ' + 'yeah okay ok um uh really very actually going gonna get got do does did dont don\'t im i\'m it\'s thats that\'s youre you\'re ' + 'about all also can could would should one two some more most much many been being because how why where here now know think ' + 'thing things people said say says want make made time right well let lets let\'s kind sort mean guess stuff lot little ' + 'first second third version episode listening welcome today back show talking talk thanks next every single least before after ' + 'would could should never always over under still even only same other another those these into onto than tell tells told give gave').split(/\s+/)); // Phrases that make a viewer stop scrolling (weights are relative; tuned on podcast transcripts). const HOOKS = [ [/\b(secret|nobody (tells|talks)|no one (tells|talks)|the truth|biggest mistake|mistakes?|the trick|here'?s (why|how|the thing|what)|what (nobody|most people))\b/i, 3], [/\b(never|always|stop|don'?t|worst|best|insane|crazy|shocking|surpris\w*|wrong|myth|lie|lied|honestly|truth)\b/i, 1.5], [/\b(how to|how i|how we|why you|why most|the reason|the one thing|the (first|second|third|only))\b/i, 2], [/\b(\d[\d,.]*\s?(%|percent|k|thousand|million|billion|x|times|hours?|days?|weeks?|years?|dollars?|views?)|\$\s?\d)/i, 2], [/\?/, 1], ]; const PAYOFF = /\b(that'?s (the|why|how|it)|the whole trick|so the lesson|the point is|bottom line|in short|which means|that'?s what)\b/i; const LAUGH = /\[(laugh\w*|applause|chuckl\w*)\]|\((laugh\w*|applause)\)|\b(ha(ha)+|lol)\b/i; const WEAK_START = /^(and|but|so|or|because|um|uh|like|yeah|okay|right)\b/i; const FILLER = /\b(um+|uh+|you know|i mean|sort of|kind of)\b/gi; export const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim(); const wordsOf = (s) => clean(s).split(' ').filter(Boolean); /** Whisper chunks [{timestamp:[s,e], text}] -> [{start, end, text}] (drops empties, fixes null ends). */ export function toSegments(chunks, duration = Infinity) { const out = []; for (const c of chunks || []) { const text = clean(c.text); if (!text || /^\[(blank_audio|music|silence)\]$/i.test(text)) continue; const [s, e] = c.timestamp || [c.start, c.end]; const start = Math.max(0, Number(s) || 0); let end = Number(e); if (!Number.isFinite(end) || end <= start) end = Math.min(duration, start + Math.max(1.5, wordsOf(text).length * 0.35)); if (out.length && text === out[out.length - 1].text) continue; // Whisper repeats a line at window seams out.push({ start, end, text }); } return out; } /** Word timings spread over each segment by character length (Whisper gives segment-level stamps). */ export function toWords(segments) { const words = []; for (const s of segments) { const w = wordsOf(s.text); if (!w.length) continue; const weights = w.map((x) => Math.max(2, x.replace(/[^\p{L}\p{N}]/gu, '').length + 1)); const total = weights.reduce((a, b) => a + b, 0); let t = s.start; const span = Math.max(0.2, s.end - s.start); w.forEach((text, i) => { const d = (span * weights[i]) / total; words.push({ text, start: t, end: t + d }); t += d; }); } return words; } /** RMS loudness per `step` seconds from 16 kHz mono samples. */ export function envelope(audio, rate = 16000, step = 0.5) { const n = Math.max(1, Math.floor(rate * step)); const out = new Float32Array(Math.ceil(audio.length / n)); for (let i = 0; i < out.length; i++) { let sum = 0; const a = i * n, b = Math.min(audio.length, a + n); for (let j = a; j < b; j++) sum += audio[j] * audio[j]; out[i] = Math.sqrt(sum / Math.max(1, b - a)); } return { step, values: out }; } function energyOf(env, start, end) { if (!env || !env.values?.length) return null; const a = Math.floor(start / env.step), b = Math.max(a + 1, Math.ceil(end / env.step)); let s = 0, n = 0; for (let i = a; i < b && i < env.values.length; i++) { s += env.values[i]; n++; } return n ? s / n : null; } function mean(env) { if (!env || !env.values?.length) return null; let s = 0, n = 0; for (const v of env.values) if (v > 0.003) { s += v; n++; } return n ? s / n : null; } /** Candidate windows that start on a segment boundary and end on one, lasting minLen..maxLen seconds. */ export function candidates(segments, { minLen = 15, maxLen = 60 } = {}) { const out = []; for (let i = 0; i < segments.length; i++) { let text = ''; for (let j = i; j < segments.length; j++) { text += (text ? ' ' : '') + segments[j].text; const dur = segments[j].end - segments[i].start; if (dur > maxLen) break; if (dur >= minLen) out.push({ i, j, start: segments[i].start, end: segments[j].end, text }); } } return out; } /** Score one window. Returns { score, reasons[] } with every contribution named. */ export function scoreWindow(w, segments, env, avgEnergy, { target = 35 } = {}) { const reasons = []; let score = 0; const add = (v, why) => { if (v) { score += v; reasons.push(why); } }; const first = segments[w.i].text; const opener = clean(segments.slice(w.i, w.i + 2).map((s) => s.text).join(' ')).slice(0, 160); let hook = 0; for (const [re, wt] of HOOKS) if (re.test(opener)) hook += wt; add(Math.min(hook, 6) * 1.2, hook ? 'strong hook in the first seconds' : ''); let body = 0; for (const [re, wt] of HOOKS) if (re.test(w.text)) body += wt * 0.35; add(Math.min(body, 3), ''); add(PAYOFF.test(w.text) ? 2 : 0, 'has a payoff line'); add(/[.!?]["')\]]?$/.test(w.text.trim()) ? 1.5 : -1.5, /[.!?]["')\]]?$/.test(w.text.trim()) ? 'ends on a complete sentence' : 'ends mid-sentence'); add(/^[A-Z0-9"'$]/.test(first) && !WEAK_START.test(first) ? 1 : -1, WEAK_START.test(first) ? 'starts on a filler word' : 'clean start'); add(LAUGH.test(w.text) ? 2 : 0, 'laughter or reaction'); const dur = w.end - w.start; const n = wordsOf(w.text).length; const wps = n / Math.max(1, dur); add(wps >= 2.2 && wps <= 4.2 ? 1 : wps < 1.4 ? -2 : 0, wps >= 2.2 && wps <= 4.2 ? 'lively pace' : wps < 1.4 ? 'slow or silent stretch' : ''); const fillers = (w.text.match(FILLER) || []).length; add(fillers > 3 ? -Math.min(3, fillers * 0.3) : 0, fillers > 3 ? 'many filler words' : ''); const gap = w.i > 0 ? segments[w.i].start - segments[w.i - 1].end : 1; add(gap >= 0.6 ? 0.8 : 0, gap >= 0.6 ? 'begins after a pause' : ''); const e = energyOf(env, w.start, w.end); if (e != null && avgEnergy) { const r = e / avgEnergy; add(r > 1.15 ? Math.min(2, (r - 1) * 4) : r < 0.75 ? -1 : 0, r > 1.15 ? 'louder, more energetic audio' : r < 0.75 ? 'quiet audio' : ''); } add(-Math.abs(dur - target) / 12, ''); return { score: Math.round(score * 100) / 100, reasons: reasons.filter(Boolean) }; } /** Ranked, non-overlapping moments. */ export function pickMoments(segments, env, { count = 10, minLen = 15, maxLen = 60, target = 35 } = {}) { const avg = mean(env); const scored = candidates(segments, { minLen, maxLen }).map((w) => ({ ...w, ...scoreWindow(w, segments, env, avg, { target }) })); scored.sort((a, b) => b.score - a.score || a.start - b.start); const picked = []; const bag = (t) => new Set(wordsOf(t.toLowerCase()).map((w) => w.replace(/[^\p{L}\p{N}]/gu, '')).filter((w) => w.length > 2)); const similar = (a, b) => { let n = 0; for (const w of a) if (b.has(w)) n++; return n / Math.max(1, Math.min(a.size, b.size)); }; for (const c of scored) { if (picked.length >= count) break; if (picked.some((p) => c.start < p.end - 1 && c.end > p.start + 1)) continue; c.bag = bag(c.text); if (picked.some((p) => similar(c.bag, p.bag) > 0.7)) continue; // the same story told twice (re-runs, intros, ad reads) picked.push(c); } return picked.map((c, k) => ({ id: k + 1, start: c.start, end: c.end, text: c.text, score: c.score, reasons: c.reasons, ...clipMeta(c.text) })); } /** Also exported for the transcript-only Hook Finder: plain text / SRT / VTT -> segments. */ export function parseTranscript(src) { const txt = String(src || '').replace(/\r/g, ''); const ts = (h, m, s, ms) => (+h || 0) * 3600 + (+m) * 60 + (+s) + (+(ms || 0)) / 1000; const re = /(?:(\d+):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?\s*-->\s*(?:(\d+):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?[^\n]*\n([\s\S]*?)(?=\n\s*\n|\n?$)/g; const out = []; let m; while ((m = re.exec(txt))) { const text = clean(m[9].replace(/<[^>]+>/g, '').replace(/^\d+$/gm, '')); if (text) out.push({ start: ts(m[1], m[2], m[3], (m[4] || '0').padEnd(3, '0')), end: ts(m[5], m[6], m[7], (m[8] || '0').padEnd(3, '0')), text }); } if (out.length) return out; // Plain text: optional [hh:mm:ss] or (mm:ss) stamps; otherwise sentences at ~2.8 words per second. const sentences = txt.split(/\n+|(?<=[.!?])\s+/).map(clean).filter(Boolean); let t = 0; for (const s of sentences) { const st = s.match(/^[[(]?(?:(\d+):)?(\d{1,2}):(\d{2})[\])]?\s*/); if (st) t = ts(st[1], st[2], st[3]); const text = st ? s.slice(st[0].length) : s; if (!text) continue; const d = Math.max(1.2, wordsOf(text).length / 2.8); out.push({ start: t, end: t + d, text }); t += d; } return out; } const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1); /** Title, description and hashtags from the clip's own words (local, no AI). */ export function clipMeta(text) { const sentences = clean(text).split(/(?<=[.!?])\s+/).filter(Boolean); let best = sentences[0] || clean(text); let bestScore = -1; for (const s of sentences.slice(0, 6)) { let sc = 0; for (const [re, wt] of HOOKS) if (re.test(s)) sc += wt; sc -= Math.abs(wordsOf(s).length - 11) * 0.08; if (sc > bestScore) { bestScore = sc; best = s; } } let title = best.replace(WEAK_START, '').replace(/^[,\s]+/, '').replace(/[.,;:]+$/, ''); if (title.length > 70) title = title.slice(0, 67).replace(/\s+\S*$/, '') + '…'; title = cap(title); const counts = new Map(); for (const w of wordsOf(text.toLowerCase())) { const k = w.replace(/[^\p{L}\p{N}]/gu, ''); if (k.length < 4 || STOP.has(k) || /^\d+$/.test(k)) continue; counts.set(k, (counts.get(k) || 0) + 1); } const top = [...counts.entries()].sort((a, b) => b[1] - a[1] || b[0].length - a[0].length).slice(0, 3).map(([k]) => `#${k}`); const hashtags = [...new Set([...top, '#podcast', '#shorts'])].slice(0, 5); const description = `${sentences.slice(0, 2).join(' ').slice(0, 220)}${sentences.length > 2 ? '…' : ''}`; return { title, description, hashtags }; } export const clock = (s) => { s = Math.max(0, Math.round(s)); const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), x = s % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`; };