Add a lyrics-only service mode view with the sung line centred and auto-sized, and a free local faster-whisper script to transcribe saved songs
This commit is contained in:
17
CLAUDE.md
17
CLAUDE.md
@@ -91,6 +91,23 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
|
|||||||
edit feed with view/restore, caption injection, JSON export, API examples.
|
edit feed with view/restore, caption injection, JSON export, API examples.
|
||||||
Disabled unless `ADMIN_PASSWORD` is set (Dokploy Environment tab; compose passes
|
Disabled unless `ADMIN_PASSWORD` is set (Dokploy Environment tab; compose passes
|
||||||
`${ADMIN_PASSWORD:-}`). Login is rate-limited per IP.
|
`${ADMIN_PASSWORD:-}`). Login is rate-limited per IP.
|
||||||
|
- **Service mode → 🎵 Lyrics** (`data.settings.serviceLyrics`, `serviceLyricsAutoscroll`):
|
||||||
|
lyrics-only view; the sung line is centred, highlighted and sized to the
|
||||||
|
largest font that fits ONE row. Sizing is measured with a hidden real
|
||||||
|
`.sl-line` in TWO passes — Bricolage Grotesque has an optical-size axis, so
|
||||||
|
glyphs get relatively wider at small sizes and a single 100px measurement
|
||||||
|
over-fits on phones. Re-fit (in place, never rebuilding rows — that would
|
||||||
|
move the scroll position) on resize, `document.fonts` load, and +900 ms.
|
||||||
|
Lyrics-only and the 📺 video panel are mutually exclusive.
|
||||||
|
- **Free batch transcription**: `scripts/lyrics/auto_lyrics.py` (faster-whisper
|
||||||
|
`large-v3-turbo` int8 on CPU, no API key/credits; venv at
|
||||||
|
`~/.local/share/lyrics-asr/.venv`). `--missing` uses `GET /api/admin/media`
|
||||||
|
(admin or token). Measured on the 16-core laptop: ~0.3–0.65× real time;
|
||||||
|
openai-whisper medium was ~2.5×. **`vad_filter` must stay False** — VAD calls
|
||||||
|
sung music non-speech and returns an empty transcript. Karaoke/minus-one
|
||||||
|
tracks have no vocals (reported "instrumental?"); their lyrics were read from
|
||||||
|
the on-screen text (frame sampling + OCR), not audio. ElevenLabs Scribe also
|
||||||
|
works but spends the TTS credit balance (free tier = 10k/month).
|
||||||
- Offline: the last-seen notes per video are cached in localStorage
|
- Offline: the last-seen notes per video are cached in localStorage
|
||||||
(`ytpNotesCache`, 200 videos), so saved songs keep their lyrics offline.
|
(`ytpNotesCache`, 200 videos), so saved songs keep their lyrics offline.
|
||||||
- **Toast cap bug (fixed with this feature):** `toast()` used
|
- **Toast cap bug (fixed with this feature):** `toast()` used
|
||||||
|
|||||||
159
frontend/app.js
159
frontend/app.js
@@ -2283,7 +2283,7 @@ function updateNowPlayingActions() {
|
|||||||
const Notes = (() => {
|
const Notes = (() => {
|
||||||
const LC = window.LyricsCore;
|
const LC = window.LyricsCore;
|
||||||
const panel = $('notesPanel');
|
const panel = $('notesPanel');
|
||||||
const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; } };
|
const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; }, serviceRefresh() {} };
|
||||||
if (!panel || !LC) return noop;
|
if (!panel || !LC) return noop;
|
||||||
|
|
||||||
const CACHE_KEY = 'ytpNotesCache';
|
const CACHE_KEY = 'ytpNotesCache';
|
||||||
@@ -2343,8 +2343,10 @@ const Notes = (() => {
|
|||||||
endSync();
|
endSync();
|
||||||
if (!supported(meta)) {
|
if (!supported(meta)) {
|
||||||
st.videoId = null;
|
st.videoId = null;
|
||||||
|
st.lyrics = null;
|
||||||
panel.classList.add('hidden');
|
panel.classList.add('hidden');
|
||||||
renderSeekMarkers();
|
renderSeekMarkers();
|
||||||
|
renderService(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = meta.id;
|
const id = meta.id;
|
||||||
@@ -2384,6 +2386,7 @@ const Notes = (() => {
|
|||||||
// ---- rendering ----
|
// ---- rendering ----
|
||||||
function renderAll() {
|
function renderAll() {
|
||||||
renderLyrics();
|
renderLyrics();
|
||||||
|
renderService(true);
|
||||||
renderMarkers();
|
renderMarkers();
|
||||||
renderSeekMarkers();
|
renderSeekMarkers();
|
||||||
renderOffset();
|
renderOffset();
|
||||||
@@ -2496,6 +2499,7 @@ const Notes = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function tick(cur, force = false) {
|
function tick(cur, force = false) {
|
||||||
|
tickService(cur, force);
|
||||||
if (!st.videoId || panel.classList.contains('hidden')) return;
|
if (!st.videoId || panel.classList.contains('hidden')) return;
|
||||||
const dur = (Player.master && Player.master.duration) || 0;
|
const dur = (Player.master && Player.master.duration) || 0;
|
||||||
if (dur && isFinite(dur) && Math.abs(dur - st.lastDur) > 0.5) renderSeekMarkers();
|
if (dur && isFinite(dur) && Math.abs(dur - st.lastDur) > 0.5) renderSeekMarkers();
|
||||||
@@ -2910,6 +2914,153 @@ const Notes = (() => {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- service mode: lyrics only ----
|
||||||
|
// The sung line sits in the middle of the screen at the largest size that
|
||||||
|
// still fits on ONE row (measured per line with a canvas), highlighted; its
|
||||||
|
// neighbours shrink and fade with distance. Auto-scroll keeps it centred.
|
||||||
|
const sv = { key: '', idx: -2, timer: null };
|
||||||
|
const serviceOn = () => isServiceModeOpen() && !!data.settings.serviceLyrics;
|
||||||
|
const serviceAuto = () => data.settings.serviceLyricsAutoscroll !== false;
|
||||||
|
const sungLines = () => lines().map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line');
|
||||||
|
|
||||||
|
// Measured with a hidden row styled exactly like .sl-line, so the size
|
||||||
|
// comes from the same font resolution the screen uses (a canvas can
|
||||||
|
// measure with a fallback face while the web font's 800 weight loads).
|
||||||
|
function fitSizes(list, texts) {
|
||||||
|
const width = list.clientWidth * 0.92 * 0.97; // 4% side padding + slack
|
||||||
|
const maxPx = Math.max(30, Math.min(list.clientHeight * 0.17, 160));
|
||||||
|
const minPx = 18;
|
||||||
|
// A real .sl-line (same font rules) forced onto one unpadded row.
|
||||||
|
const probe = document.createElement('div');
|
||||||
|
probe.className = 'sl-line';
|
||||||
|
probe.style.cssText = 'position:absolute;left:0;top:0;visibility:hidden;white-space:nowrap;width:auto;padding:0;font-size:100px;transition:none';
|
||||||
|
list.appendChild(probe);
|
||||||
|
const clamp = (px) => Math.max(minPx, Math.min(maxPx, px));
|
||||||
|
const widthAt = (px) => { probe.style.fontSize = px + 'px'; return probe.getBoundingClientRect().width; };
|
||||||
|
// Two passes: the display font has an optical-size axis, so glyphs get
|
||||||
|
// relatively WIDER as the size drops — a single measurement at 100px
|
||||||
|
// overestimates what fits at phone sizes.
|
||||||
|
const out = texts.map((text) => {
|
||||||
|
probe.textContent = text;
|
||||||
|
const w100 = widthAt(100);
|
||||||
|
if (!w100) return maxPx;
|
||||||
|
const est = clamp((width / w100) * 100);
|
||||||
|
const wEst = widthAt(est);
|
||||||
|
return Math.floor(clamp(wEst ? est * (width / wEst) : est));
|
||||||
|
});
|
||||||
|
probe.remove();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-measure every row in place (no rebuild, so the scroll position and
|
||||||
|
// the highlight are untouched).
|
||||||
|
function applyFits() {
|
||||||
|
const list = $('serviceLyricsList');
|
||||||
|
const rows = [...list.querySelectorAll('.sl-line')];
|
||||||
|
if (!rows.length || !list.clientWidth) return;
|
||||||
|
const fits = fitSizes(list, rows.map((el) => el.textContent));
|
||||||
|
rows.forEach((el, k) => el.style.setProperty('--fit', fits[k] + 'px'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServiceButtons() {
|
||||||
|
const on = !!data.settings.serviceLyrics;
|
||||||
|
const lb = $('serviceLyricsBtn');
|
||||||
|
lb.textContent = on ? '🎵 Lyrics on' : '🎵 Lyrics off';
|
||||||
|
lb.classList.toggle('on', on);
|
||||||
|
lb.setAttribute('aria-pressed', String(on));
|
||||||
|
const ab = $('serviceAutoscrollBtn');
|
||||||
|
ab.classList.toggle('hidden', !on);
|
||||||
|
ab.textContent = serviceAuto() ? '⇅ Auto-scroll on' : '⇅ Auto-scroll off';
|
||||||
|
ab.classList.toggle('on', serviceAuto());
|
||||||
|
ab.setAttribute('aria-pressed', String(serviceAuto()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderService(force = false) {
|
||||||
|
renderServiceButtons();
|
||||||
|
const on = serviceOn();
|
||||||
|
$('serviceMode').classList.toggle('lyrics-on', on);
|
||||||
|
$('serviceLyrics').classList.toggle('hidden', !on);
|
||||||
|
if (!on) { sv.key = ''; return; }
|
||||||
|
const list = $('serviceLyricsList');
|
||||||
|
const items = sungLines();
|
||||||
|
const key = `${st.videoId}|${st.lyrics ? st.lyrics.rev : 0}|${items.length}|${list.clientWidth}x${list.clientHeight}`;
|
||||||
|
if (key === sv.key && !force) return;
|
||||||
|
sv.key = key;
|
||||||
|
sv.idx = -2;
|
||||||
|
// Half-height spacers let the first and last line reach the centre.
|
||||||
|
const pad = Math.round(list.clientHeight / 2);
|
||||||
|
list.style.paddingTop = pad + 'px';
|
||||||
|
list.style.paddingBottom = pad + 'px';
|
||||||
|
if (!items.length) {
|
||||||
|
const title = (current && current.meta && current.meta.title) || '';
|
||||||
|
list.innerHTML = `<div class="sl-empty">${st.videoId ? 'No lyrics for this song yet' : 'Nothing playing'}${title ? `<small>${escapeHtml(title)}</small>` : ''}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = items.map((l) =>
|
||||||
|
`<div class="sl-line${l.t === null || l.t === undefined ? ' untimed' : ''}" data-i="${l.i}">${escapeHtml(l.text)}</div>`).join('');
|
||||||
|
applyFits();
|
||||||
|
tickService(now(), true);
|
||||||
|
// One more fit once the view has settled (fonts, first layout).
|
||||||
|
clearTimeout(sv.refit);
|
||||||
|
sv.refit = setTimeout(() => { if (serviceOn()) applyFits(); }, 900);
|
||||||
|
}
|
||||||
|
|
||||||
|
function centreOn(list, el) {
|
||||||
|
const go = () => list.scrollTo({ top: Math.max(0, el.offsetTop - list.clientHeight / 2 + el.offsetHeight / 2), behavior: 'smooth' });
|
||||||
|
go();
|
||||||
|
// Font sizes animate, so re-centre once the line has grown.
|
||||||
|
clearTimeout(sv.timer);
|
||||||
|
sv.timer = setTimeout(go, 380);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tickService(cur, force = false) {
|
||||||
|
if (!serviceOn()) return;
|
||||||
|
const list = $('serviceLyricsList');
|
||||||
|
const idx = LC.activeIndex(lines(), cur, offset());
|
||||||
|
if (idx === sv.idx && !force) return;
|
||||||
|
sv.idx = idx;
|
||||||
|
const rows = [...list.querySelectorAll('.sl-line')];
|
||||||
|
const pos = rows.findIndex((el) => Number(el.dataset.i) === idx);
|
||||||
|
rows.forEach((el, k) => {
|
||||||
|
const d = pos < 0 ? 99 : Math.abs(k - pos);
|
||||||
|
el.classList.toggle('active', d === 0);
|
||||||
|
el.classList.toggle('near', d === 1);
|
||||||
|
el.classList.toggle('past', pos >= 0 && k < pos);
|
||||||
|
});
|
||||||
|
if (serviceAuto() && rows.length) centreOn(list, pos >= 0 ? rows[pos] : rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceRefresh() { renderService(true); }
|
||||||
|
|
||||||
|
$('serviceLyricsBtn').addEventListener('click', () => {
|
||||||
|
data.settings.serviceLyrics = !data.settings.serviceLyrics;
|
||||||
|
// Lyrics fill the screen; the inline video panel would squeeze them.
|
||||||
|
if (data.settings.serviceLyrics && data.settings.serviceVideo) {
|
||||||
|
data.settings.serviceVideo = false;
|
||||||
|
updateServiceVideoUI();
|
||||||
|
}
|
||||||
|
persist();
|
||||||
|
renderService(true);
|
||||||
|
});
|
||||||
|
$('serviceAutoscrollBtn').addEventListener('click', () => {
|
||||||
|
data.settings.serviceLyricsAutoscroll = !serviceAuto();
|
||||||
|
persist();
|
||||||
|
renderServiceButtons();
|
||||||
|
if (serviceAuto()) tickService(now(), true);
|
||||||
|
});
|
||||||
|
$('serviceLyricsList').addEventListener('click', (e) => {
|
||||||
|
const row = e.target.closest('.sl-line');
|
||||||
|
const l = row && lines()[Number(row.dataset.i)];
|
||||||
|
if (l && l.t !== null && l.t !== undefined) Player.seek(Math.max(0, l.t + offset()));
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', () => { if (serviceOn()) renderService(); });
|
||||||
|
// Sizes are measured with the web font; a measurement taken while it was
|
||||||
|
// still loading used the narrower fallback, so re-fit once fonts land.
|
||||||
|
if (document.fonts) {
|
||||||
|
document.fonts.ready.then(() => { if (serviceOn()) applyFits(); });
|
||||||
|
document.fonts.addEventListener('loadingdone', () => { if (serviceOn()) applyFits(); });
|
||||||
|
}
|
||||||
|
|
||||||
// ---- tabs + events ----
|
// ---- tabs + events ----
|
||||||
function showTab(tab) {
|
function showTab(tab) {
|
||||||
st.tab = tab;
|
st.tab = tab;
|
||||||
@@ -2984,7 +3135,7 @@ const Notes = (() => {
|
|||||||
else if (btn.dataset.act === 'del') deleteMarker(kind, i);
|
else if (btn.dataset.act === 'del') deleteMarker(kind, i);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { load, tick, addBookmark, handleSyncKey };
|
return { load, tick, addBookmark, handleSyncKey, serviceRefresh };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -4046,8 +4197,11 @@ function toggleServiceVideo() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
data.settings.serviceVideo = !data.settings.serviceVideo;
|
data.settings.serviceVideo = !data.settings.serviceVideo;
|
||||||
|
// Video and lyrics-only share the screen; turning one on turns the other off.
|
||||||
|
if (data.settings.serviceVideo && data.settings.serviceLyrics) data.settings.serviceLyrics = false;
|
||||||
persist();
|
persist();
|
||||||
updateServiceVideoUI();
|
updateServiceVideoUI();
|
||||||
|
Notes.serviceRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function enterServiceMode(pl) {
|
function enterServiceMode(pl) {
|
||||||
@@ -4072,6 +4226,7 @@ function enterServiceMode(pl) {
|
|||||||
document.body.classList.add('service-open');
|
document.body.classList.add('service-open');
|
||||||
updateServiceVideoUI();
|
updateServiceVideoUI();
|
||||||
updateServiceMode();
|
updateServiceMode();
|
||||||
|
Notes.serviceRefresh();
|
||||||
acquireWakeLock();
|
acquireWakeLock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -319,6 +319,10 @@
|
|||||||
<div class="service-topbar">
|
<div class="service-topbar">
|
||||||
<div id="serviceWakeState" class="service-wake-state">Screen may sleep</div>
|
<div id="serviceWakeState" class="service-wake-state">Screen may sleep</div>
|
||||||
<div class="service-topbar-actions">
|
<div class="service-topbar-actions">
|
||||||
|
<button id="serviceAutoscrollBtn" class="service-exit-btn service-video-btn hidden" type="button"
|
||||||
|
aria-pressed="true" aria-label="Keep the sung line centred">⇅ Auto-scroll on</button>
|
||||||
|
<button id="serviceLyricsBtn" class="service-exit-btn service-video-btn" type="button"
|
||||||
|
aria-pressed="false" aria-label="Show only the lyrics">🎵 Lyrics off</button>
|
||||||
<button id="serviceVideoBtn" class="service-exit-btn service-video-btn" type="button"
|
<button id="serviceVideoBtn" class="service-exit-btn service-video-btn" type="button"
|
||||||
aria-pressed="false" aria-label="Show or hide the video">📺 Video off</button>
|
aria-pressed="false" aria-label="Show or hide the video">📺 Video off</button>
|
||||||
<button id="serviceExitBtn" class="service-exit-btn" type="button" aria-label="Exit Service Mode">Exit</button>
|
<button id="serviceExitBtn" class="service-exit-btn" type="button" aria-label="Exit Service Mode">Exit</button>
|
||||||
@@ -331,6 +335,10 @@
|
|||||||
<div id="serviceNote" class="service-note hidden"></div>
|
<div id="serviceNote" class="service-note hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="serviceUpNext" class="service-upnext">Last song</div>
|
<div id="serviceUpNext" class="service-upnext">Last song</div>
|
||||||
|
<!-- Lyrics-only view: the sung line is centred, largest and highlighted -->
|
||||||
|
<div id="serviceLyrics" class="service-lyrics hidden" aria-live="off">
|
||||||
|
<div id="serviceLyricsList" class="sl-list"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="service-bottom">
|
<div class="service-bottom">
|
||||||
<div id="serviceTime" class="service-time">
|
<div id="serviceTime" class="service-time">
|
||||||
|
|||||||
@@ -3715,3 +3715,66 @@ body.remote-open { overflow: hidden; }
|
|||||||
.rv-add-item b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.rv-add-item b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.rv-add-item small { color: var(--text-dim); font-size: 11px; }
|
.rv-add-item small { color: var(--text-dim); font-size: 11px; }
|
||||||
.rv-add-item.added { border-color: #7ee0a8; }
|
.rv-add-item.added { border-color: #7ee0a8; }
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* Service mode — lyrics only (Notes.renderService in app.js)
|
||||||
|
* ========================================================================== */
|
||||||
|
.service-mode.lyrics-on .service-meta,
|
||||||
|
.service-mode.lyrics-on .service-upnext { display: none; }
|
||||||
|
.service-mode.lyrics-on .service-center {
|
||||||
|
max-width: none;
|
||||||
|
padding: 8px 0;
|
||||||
|
min-height: 0;
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
.service-lyrics {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40vh;
|
||||||
|
}
|
||||||
|
.sl-list {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
|
font-family: var(--display);
|
||||||
|
/* Soft fade at the top and bottom edges so lines drift in and out. */
|
||||||
|
-webkit-mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);
|
||||||
|
mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent);
|
||||||
|
}
|
||||||
|
.sl-list::-webkit-scrollbar { display: none; }
|
||||||
|
.sl-line {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0.16em 4%;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.15;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
/* --fit = the size at which THIS line fills one row; others are fractions. */
|
||||||
|
font-size: calc(var(--fit, 40px) * 0.48);
|
||||||
|
color: var(--text-2);
|
||||||
|
opacity: 0.42;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 18px;
|
||||||
|
transition: font-size 0.35s var(--ease), opacity 0.35s var(--ease), color 0.35s var(--ease), background-color 0.35s var(--ease);
|
||||||
|
}
|
||||||
|
.sl-line.near { font-size: calc(var(--fit, 40px) * 0.6); opacity: 0.7; }
|
||||||
|
.sl-line.past { opacity: 0.28; }
|
||||||
|
.sl-line.active {
|
||||||
|
font-size: var(--fit, 40px);
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--text);
|
||||||
|
background-color: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||||
|
text-shadow: 0 0 32px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.sl-line.untimed { cursor: default; }
|
||||||
|
.sl-empty {
|
||||||
|
display: flex; flex-direction: column; gap: 10px; align-items: center;
|
||||||
|
font-size: clamp(22px, 4vw, 40px); font-weight: 700; color: var(--text-2);
|
||||||
|
}
|
||||||
|
.sl-empty small { font-family: var(--ui); font-size: 16px; font-weight: 500; color: var(--text-dim); }
|
||||||
|
|||||||
180
scripts/lyrics/auto_lyrics.py
Normal file
180
scripts/lyrics/auto_lyrics.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""auto_lyrics.py — transcribe saved songs locally and inject them as shared lyrics.
|
||||||
|
|
||||||
|
Free and offline: faster-whisper (CTranslate2, int8, CPU) runs on this machine —
|
||||||
|
no API key, no credits. Audio comes from the server's own cache
|
||||||
|
(/api/media/<id>?a=1), results go to /api/notes/<id>/lyrics with baseRev, so a
|
||||||
|
song someone already has lyrics for is never overwritten unless --overwrite.
|
||||||
|
|
||||||
|
Setup (once):
|
||||||
|
uv venv ~/.local/share/lyrics-asr/.venv --python 3.12
|
||||||
|
~/.local/share/lyrics-asr/.venv/bin/pip install -r scripts/lyrics/requirements.txt
|
||||||
|
Run:
|
||||||
|
YTP_TOKEN=ytp_… .venv/bin/python scripts/lyrics/auto_lyrics.py --missing
|
||||||
|
YTP_ADMIN_PASSWORD=… … --ids KcrXlpg0LKI,65vdbMHh4mo --dry-run
|
||||||
|
Measured on the 16-core WSL laptop: large-v3-turbo ≈ 0.65× real time
|
||||||
|
(6.5-min song in ~4 min); openai-whisper medium was ~2.5× real time.
|
||||||
|
Karaoke / minus-one tracks have no vocals; they are reported as "instrumental"
|
||||||
|
and skipped (their words are on screen — see the repo CLAUDE.md).
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import http.cookiejar
|
||||||
|
|
||||||
|
FILLER = re.compile(r"^(?:(?:oh|ooh|ohh|oh-oh|ah|ahh|hey|yeah|mm|mm-mm|mm-mm-mm|hmm|whoa|woah|la|na|uh|come on)[\s,.!?-]*)+$", re.I)
|
||||||
|
KEEP_CAP = {'I', "I'm", "I'll", "I've", "I'd", 'You', 'Your', "You're", 'Yours', 'Lord', 'God', 'Jesus', 'Christ',
|
||||||
|
'He', 'His', 'Him', 'Thee', 'Thy', 'Thou', 'Father', 'Spirit', 'Holy', 'Savior', 'Saviour', 'King', 'Lamb',
|
||||||
|
'Earth', 'Heaven', 'Panginoon', 'Diyos', 'Hesus', 'Ikaw', 'Iyo', 'Iyong'}
|
||||||
|
|
||||||
|
|
||||||
|
def segment(words, max_words=9, gap_break=1.0):
|
||||||
|
"""Word timings -> sung lines. Whisper punctuates songs sparsely, so lines
|
||||||
|
break on sentence ends, pauses, capitalised line starts and a length cap;
|
||||||
|
1-2 word fragments (a held note split a phrase) fold into the next line."""
|
||||||
|
ws = [w for w in words if w['text'].strip()]
|
||||||
|
groups, cur = [], []
|
||||||
|
for w in ws:
|
||||||
|
if cur:
|
||||||
|
gap = w['start'] - cur[-1]['end']
|
||||||
|
prev = cur[-1]['text'].strip()
|
||||||
|
word = w['text'].strip()
|
||||||
|
n = len(cur)
|
||||||
|
cap_start = word[:1].isupper() and word.strip('",.!?') not in KEEP_CAP
|
||||||
|
if (re.search(r'[.!?]$', prev) or gap >= gap_break or n >= max_words
|
||||||
|
or (re.search(r'[,;:]$', prev) and n >= 4) or (cap_start and n >= 3)
|
||||||
|
or (word in ('You', 'I') and n >= 5)):
|
||||||
|
groups.append(cur)
|
||||||
|
cur = []
|
||||||
|
cur.append(w)
|
||||||
|
if cur:
|
||||||
|
groups.append(cur)
|
||||||
|
folded, i = [], 0
|
||||||
|
while i < len(groups):
|
||||||
|
g = groups[i]
|
||||||
|
if (len(g) <= 2 and i + 1 < len(groups) and not re.search(r'[.!?]$', g[-1]['text'].strip())
|
||||||
|
and groups[i + 1][0]['start'] - g[-1]['end'] < 4):
|
||||||
|
groups[i + 1] = g + groups[i + 1]
|
||||||
|
else:
|
||||||
|
folded.append(g)
|
||||||
|
i += 1
|
||||||
|
lines = []
|
||||||
|
for g in folded:
|
||||||
|
text = re.sub(r'\s+([,.!?;:])', r'\1', ' '.join(w['text'].strip() for w in g)).strip().rstrip(',;:')
|
||||||
|
if re.sub(r'[\W_]+', '', text):
|
||||||
|
lines.append({'t': round(g[0]['start'], 2), 'end': g[-1]['end'], 'text': text[:300], 'n': len(g)})
|
||||||
|
out = []
|
||||||
|
for i, l in enumerate(lines):
|
||||||
|
nxt = lines[i + 1]['t'] if i + 1 < len(lines) else l['end'] + 99
|
||||||
|
prv = lines[i - 1]['end'] if i else -99
|
||||||
|
if l['n'] == 1 and nxt - l['end'] > 4 and l['t'] - prv > 4:
|
||||||
|
continue # isolated one-word line: intro/outro hallucination
|
||||||
|
if FILLER.match(l['text']):
|
||||||
|
continue
|
||||||
|
out.append({'t': l['t'], 'text': l['text'], 'kind': 'line'})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class Api:
|
||||||
|
def __init__(self, base, token=None, password=None):
|
||||||
|
self.base = base.rstrip('/')
|
||||||
|
self.token = token
|
||||||
|
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
||||||
|
if not token and password:
|
||||||
|
st, r = self.call('POST', '/api/admin/login', {'password': password})
|
||||||
|
if st != 200:
|
||||||
|
sys.exit(f'admin login failed: {r}')
|
||||||
|
|
||||||
|
def call(self, method, path, body=None, raw=False):
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if self.token:
|
||||||
|
headers['Authorization'] = f'Bearer {self.token}'
|
||||||
|
req = urllib.request.Request(self.base + path, method=method, headers=headers,
|
||||||
|
data=json.dumps(body).encode() if body is not None else None)
|
||||||
|
try:
|
||||||
|
with self.opener.open(req, timeout=600) as r:
|
||||||
|
data = r.read()
|
||||||
|
return r.status, data if raw else json.loads(data or b'{}')
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
data = e.read()
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(data or b'{}')
|
||||||
|
except ValueError:
|
||||||
|
return e.code, {'error': data[:200].decode('utf-8', 'replace')}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument('--base', default=os.environ.get('YTP_BASE', 'https://worship.hesed.sbs'))
|
||||||
|
pick = ap.add_mutually_exclusive_group(required=True)
|
||||||
|
pick.add_argument('--missing', action='store_true', help='every saved video without lyrics')
|
||||||
|
pick.add_argument('--ids', help='comma-separated video ids')
|
||||||
|
ap.add_argument('--overwrite', action='store_true', help='replace existing lyrics (kept in history)')
|
||||||
|
ap.add_argument('--model', default='large-v3-turbo')
|
||||||
|
ap.add_argument('--language', default=None, help='e.g. en, tl (default: auto-detect)')
|
||||||
|
ap.add_argument('--threads', type=int, default=os.cpu_count() or 4)
|
||||||
|
ap.add_argument('--dry-run', action='store_true', help='transcribe and print, do not upload')
|
||||||
|
ap.add_argument('--min-words', type=int, default=25, help='fewer words = treat as instrumental')
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
api = Api(args.base, os.environ.get('YTP_TOKEN'), os.environ.get('YTP_ADMIN_PASSWORD'))
|
||||||
|
if args.missing:
|
||||||
|
st, r = api.call('GET', '/api/admin/media')
|
||||||
|
if st != 200:
|
||||||
|
sys.exit(f'listing saved videos failed ({st}): {r.get("error")} — set YTP_TOKEN or YTP_ADMIN_PASSWORD')
|
||||||
|
todo = [m['id'] for m in r['media'] if args.overwrite or not m['lyricsLines']]
|
||||||
|
else:
|
||||||
|
todo = [x.strip() for x in args.ids.split(',') if x.strip()]
|
||||||
|
print(f'{len(todo)} video(s) to transcribe with {args.model}', flush=True)
|
||||||
|
if not todo:
|
||||||
|
return
|
||||||
|
|
||||||
|
from faster_whisper import WhisperModel # imported late: listing works without it
|
||||||
|
model = WhisperModel(args.model, device='cpu', compute_type='int8', cpu_threads=args.threads)
|
||||||
|
summary = []
|
||||||
|
for vid in todo:
|
||||||
|
st, cur = api.call('GET', f'/api/notes/{vid}')
|
||||||
|
live = (cur or {}).get('lyrics') if st == 200 else None
|
||||||
|
if live and live['data']['lines'] and not args.overwrite:
|
||||||
|
summary.append((vid, 'skip: has lyrics'))
|
||||||
|
continue
|
||||||
|
st, audio = api.call('GET', f'/api/media/{vid}?a=1', raw=True)
|
||||||
|
if st != 200:
|
||||||
|
summary.append((vid, f'no cached audio ({st})'))
|
||||||
|
continue
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.m4a') as f:
|
||||||
|
f.write(audio)
|
||||||
|
f.flush()
|
||||||
|
t0 = time.time()
|
||||||
|
# vad_filter must stay OFF: it classifies sung music as non-speech
|
||||||
|
# and silently drops the whole song.
|
||||||
|
segs, info = model.transcribe(f.name, language=args.language, word_timestamps=True, vad_filter=False,
|
||||||
|
beam_size=5, condition_on_previous_text=False)
|
||||||
|
words = [{'text': w.word.strip(), 'start': w.start, 'end': w.end}
|
||||||
|
for s in segs for w in (s.words or []) if w.word.strip()]
|
||||||
|
took = time.time() - t0
|
||||||
|
if len(words) < args.min_words:
|
||||||
|
summary.append((vid, f'instrumental? only {len(words)} words — skipped'))
|
||||||
|
continue
|
||||||
|
lines = segment(words)
|
||||||
|
doc = {'lines': lines, 'tags': ['auto-transcribed (whisper)'], 'offset': 0}
|
||||||
|
head = ' / '.join(l['text'] for l in lines[:3])
|
||||||
|
print(f'{vid}: {len(lines)} lines, lang={info.language}, {took:.0f}s for {info.duration:.0f}s audio | {head[:100]}', flush=True)
|
||||||
|
if args.dry_run:
|
||||||
|
print(json.dumps(doc, ensure_ascii=False)[:2000])
|
||||||
|
summary.append((vid, f'dry-run {len(lines)} lines'))
|
||||||
|
continue
|
||||||
|
body = {'data': doc, 'baseRev': live['rev'] if live else 0}
|
||||||
|
st, r = api.call('PUT', f'/api/notes/{vid}/lyrics', body)
|
||||||
|
summary.append((vid, f'saved rev {r.get("rev")}' if st == 200 else f'upload failed {st}: {r.get("error")}'))
|
||||||
|
print('\n'.join(f'{v} {s}' for v, s in summary))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
1
scripts/lyrics/requirements.txt
Normal file
1
scripts/lyrics/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
faster-whisper>=1.2
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
* POST /api/admin/login | /api/admin/logout, GET /api/admin/me
|
* POST /api/admin/login | /api/admin/logout, GET /api/admin/me
|
||||||
* GET|POST /api/admin/tokens, DELETE /api/admin/tokens/:id
|
* GET|POST /api/admin/tokens, DELETE /api/admin/tokens/:id
|
||||||
* GET /api/admin/notes/recent, GET /api/admin/notes/export
|
* GET /api/admin/notes/recent, GET /api/admin/notes/export
|
||||||
|
* GET /api/admin/media (admin or token) saved videos + lyrics status
|
||||||
* POST /api/admin/notes/:id/:kind/restore { rev }
|
* POST /api/admin/notes/:id/:kind/restore { rev }
|
||||||
* GET /admin → admin.html
|
* GET /admin → admin.html
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
@@ -436,6 +437,32 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
return ok ? c.json({ ok: true }) : c.json({ ok: false, error: 'not found' }, 404);
|
return ok ? c.json({ ok: true }) : c.json({ ok: false, error: 'not found' }, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Admin cookie OR an API token — for scripts (e.g. scripts/lyrics).
|
||||||
|
const requireAdminOrToken = async (c, next) => {
|
||||||
|
const who = await resolveWriter(c, {});
|
||||||
|
if (!who || who.invalid || who.via === 'user') return c.json({ ok: false, error: 'API token or admin login required' }, 401);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Saved (server-cached) videos with whether each already has lyrics — the
|
||||||
|
// work list for batch lyric injection.
|
||||||
|
app.get('/api/admin/media', requireAdminOrToken, async (c) => {
|
||||||
|
const rows = (await db.listMedia()).filter((r) => r.status === 'ready');
|
||||||
|
const out = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
let meta = {};
|
||||||
|
try { meta = JSON.parse(r.meta || '{}'); } catch { /* corrupt meta */ }
|
||||||
|
const n = await db.getNotes(r.video_id);
|
||||||
|
out.push({
|
||||||
|
id: r.video_id, title: meta.title || '', channel: meta.channel || meta.uploader || '',
|
||||||
|
duration: Number(r.duration) || 0, lastAccess: Number(r.last_access) || 0,
|
||||||
|
lyricsRev: n.lyrics ? n.lyrics.rev : 0, lyricsLines: n.lyrics ? n.lyrics.data.lines.length : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort((a, b) => b.lastAccess - a.lastAccess);
|
||||||
|
return c.json({ ok: true, media: out });
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/admin/notes/recent', requireAdmin, async (c) => c.json({ ok: true, revs: await db.recentNoteRevs(150) }));
|
app.get('/api/admin/notes/recent', requireAdmin, async (c) => c.json({ ok: true, revs: await db.recentNoteRevs(150) }));
|
||||||
|
|
||||||
app.post('/api/admin/notes/:id/:kind/restore', requireAdmin, async (c) => {
|
app.post('/api/admin/notes/:id/:kind/restore', requireAdmin, async (c) => {
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ describe('routes', () => {
|
|||||||
const recent = await (await app.request('/api/admin/notes/recent', { headers: { Cookie: cookie } })).json();
|
const recent = await (await app.request('/api/admin/notes/recent', { headers: { Cookie: cookie } })).json();
|
||||||
expect(recent.revs[0].rev).toBe(4);
|
expect(recent.revs[0].rev).toBe(4);
|
||||||
|
|
||||||
|
// Media work list: token or admin only.
|
||||||
|
expect((await app.request('/api/admin/media')).status).toBe(401);
|
||||||
|
expect((await app.request('/api/admin/media', { headers: auth })).status).toBe(200);
|
||||||
|
|
||||||
// Revoked tokens stop working.
|
// Revoked tokens stop working.
|
||||||
await app.request(`/api/admin/tokens/${created.id}`, { method: 'DELETE', headers: { Cookie: cookie } });
|
await app.request(`/api/admin/tokens/${created.id}`, { method: 'DELETE', headers: { Cookie: cookie } });
|
||||||
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: { lines: [] }, baseRev: 4 }, auth));
|
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: { lines: [] }, baseRev: 4 }, auth));
|
||||||
|
|||||||
Reference in New Issue
Block a user