clipwhit / lib /scoring.js
CyberMax-tools's picture
Deploy clipwhit
be07307 verified
Raw History Blame Contribute Delete
11.3 kB
// 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')}`;
};