Spaces:
Running
Running
Download lib/scoring.js from CyberMax-tools/clipwhit: direct link, hf CLI and curl.
- Browser
- Download file 11.3 kB
-
https://huggingface.co/spaces/CyberMax-tools/clipwhit/resolve/main/lib/scoring.js
- Command line
-
hf download hf://spaces/CyberMax-tools/clipwhit/lib/scoring.js
-
curl -L -o scoring.js https://huggingface.co/spaces/CyberMax-tools/clipwhit/resolve/main/lib/scoring.js
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')}`; | |
| }; | |