playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+633
View File
@@ -0,0 +1,633 @@
<script>
// The tap-to-sync editor — the research bundle's "minimal correction
// editor", first scope: play the song and press Space (or click) as each
// line begins; nudge or ripple-shift stamps afterwards; optionally
// pre-fill ESTIMATED times from the measured vocal-activity envelope.
// Saving writes the track's own user document, which outranks every
// automatic source in the waterfall.
import { untrack } from "svelte";
import { player } from "../lib/state.svelte.js";
import {
syncEditor,
closeSyncEditor,
saveSyncEditor,
} from "../lib/lyrics.svelte.js";
import { roughSync } from "../lib/lyric-sync.js";
import * as api from "../lib/api.js";
import Icon from "./Icon.svelte";
import { floatScroll } from "../lib/scrollbar.js";
let cursor = $state(0); // the next row a tap stamps
let ripple = $state(true); // ± adjustments shift later stamps too
let confirmDiscard = $state(false);
let saving = $state(false);
let saveError = $state("");
let activity = $state(null); // { frameMs, frames } | null
let activityFailed = $state(false);
let roughBusy = $state(false);
let listEl = $state(null);
// Position interpolation: ticks land ~5/s; a tap between ticks reads the
// last tick plus wall-clock elapsed, so stamps carry tap precision.
let lastPos = 0;
let lastPosAt = 0;
$effect(() => {
lastPos = player.position ?? 0;
lastPosAt = performance.now();
});
function nowMs() {
const base = (player.status === "playing"
? lastPos + (performance.now() - lastPosAt) / 1000
: (player.position ?? 0));
return Math.max(0, Math.round(base * 1000));
}
// Reset per open; pull the activity envelope for the strip + rough fill.
// Keyed on the open FLIP alone — the untrack boundary is load-bearing
// twice over. Without it this effect (a) read `cursor` back after writing
// it, so the all-stamped re-time path ping-ponged -1 ↔ rows.length until
// Svelte hit effect_update_depth_exceeded and tore the subtree down,
// leaving a painted room no click could close; and (b) depended on every
// row's time_ms through findIndex, so each tap re-ran the whole reset —
// wiping the discard guard and re-running the DSP decode per stamp.
$effect(() => {
if (!syncEditor.open) return;
untrack(() => {
const firstUnstamped = syncEditor.rows.findIndex((r) => r.time_ms == null);
cursor = firstUnstamped < 0 ? syncEditor.rows.length : firstUnstamped;
confirmDiscard = false;
activity = null;
activityFailed = false;
const path = syncEditor.itemPath;
if (path) {
api.lyricsVocalActivity(path)
.then((a) => (activity = a))
.catch(() => (activityFailed = true));
} else {
activityFailed = true;
}
});
});
const stampedCount = $derived(syncEditor.rows.filter((r) => r.time_ms != null).length);
const allStamped = $derived(
syncEditor.rows.length > 0 && stampedCount === syncEditor.rows.length,
);
function stampCursor() {
if (cursor >= syncEditor.rows.length) return;
syncEditor.rows[cursor].time_ms = nowMs();
syncEditor.estimated = false;
cursor += 1;
scrollCursorIntoView();
}
function stampRow(index) {
syncEditor.rows[index].time_ms = nowMs();
syncEditor.estimated = false;
cursor = Math.max(cursor, index + 1);
}
function unstampLast() {
const last = cursor - 1;
if (last < 0) return;
syncEditor.rows[last].time_ms = null;
cursor = last;
scrollCursorIntoView();
}
function adjustRow(index, deltaMs) {
const row = syncEditor.rows[index];
if (row.time_ms == null) return;
row.time_ms = Math.max(0, row.time_ms + deltaMs);
if (ripple) {
for (let i = index + 1; i < syncEditor.rows.length; i++) {
if (syncEditor.rows[i].time_ms != null) {
syncEditor.rows[i].time_ms = Math.max(0, syncEditor.rows[i].time_ms + deltaMs);
}
}
}
}
function scrollCursorIntoView() {
requestAnimationFrame(() => {
listEl
?.querySelector(`[data-row="${Math.min(cursor, syncEditor.rows.length - 1)}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
});
}
function roughFill() {
if (!activity || roughBusy) return;
roughBusy = true;
try {
const filled = roughSync(
syncEditor.rows.map((r) => r.text).join("\n"),
activity.frames,
activity.frameMs,
);
if (filled && filled.length === syncEditor.rows.length) {
for (let i = 0; i < filled.length; i++) {
syncEditor.rows[i].time_ms = filled[i].time_ms;
}
syncEditor.estimated = true;
cursor = 0; // re-tap from the top to correct
} else {
activityFailed = true; // not enough vocal evidence — refused
}
} finally {
roughBusy = false;
}
}
async function restartSong() {
await api.playerControl("seek", 0).catch(() => {});
if (player.status !== "playing") await api.playerControl("resume").catch(() => {});
}
function back5() {
api.playerControl("seek", Math.max(0, (player.position ?? 0) - 5)).catch(() => {});
}
function togglePlay() {
api.playerControl(player.status === "playing" ? "pause" : "resume").catch(() => {});
}
async function save() {
if (!allStamped || saving) return;
saving = true;
saveError = "";
try {
const ok = await saveSyncEditor();
if (!ok) {
saveError =
"Saving failed — if the app was just updated, restart it once so the sync backend is loaded.";
}
} finally {
saving = false;
}
}
function requestClose() {
if (stampedCount > 0 && !confirmDiscard) {
confirmDiscard = true;
return;
}
closeSyncEditor();
}
function onkeydown(e) {
if (e.key === " ") {
e.preventDefault();
e.stopPropagation();
stampCursor();
} else if (e.key === "Backspace") {
e.preventDefault();
unstampLast();
} else if (e.key === "Escape") {
requestClose();
}
}
// Playing another track mid-edit: stamps would land on the wrong clock.
const wrongTrack = $derived.by(() => {
if (!syncEditor.open) return false;
const item = player.queue?.[player.queueIndex] ?? null;
const title = player.track?.title ?? "";
return title !== (syncEditor.identity?.title ?? "") && !(item?.path === syncEditor.itemPath);
});
const playheadPct = $derived.by(() => {
const duration = player.duration ?? syncEditor.identity?.durationSecs ?? 0;
if (!duration) return 0;
return Math.min(100, ((player.position ?? 0) / duration) * 100);
});
// The activity strip, downsampled to ~180 columns of inline SVG.
const strip = $derived.by(() => {
if (!activity?.frames?.length) return [];
const columns = 180;
const per = Math.max(1, Math.floor(activity.frames.length / columns));
const out = [];
for (let i = 0; i < activity.frames.length; i += per) {
let max = 0;
for (let j = i; j < Math.min(i + per, activity.frames.length); j++) {
max = Math.max(max, activity.frames[j]);
}
out.push(max);
}
return out;
});
const totalMs = $derived.by(() => {
const duration = player.duration ?? syncEditor.identity?.durationSecs ?? 0;
return duration * 1000 || (activity ? activity.frames.length * activity.frameMs : 0);
});
function fmt(ms) {
if (ms == null) return "–:––";
const total = Math.floor(ms / 1000);
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
</script>
<svelte:window onkeydown={syncEditor.open ? onkeydown : undefined} />
{#if syncEditor.open}
<div class="room">
<header>
<div class="who">
<p class="eyebrow">Sync lyrics {syncEditor.estimated ? "· estimated — tap to correct" : ""}</p>
<p class="t">{syncEditor.identity?.title ?? ""}</p>
<p class="a">{syncEditor.identity?.artist ?? ""}</p>
</div>
<div class="head-actions">
<span class="count">{stampedCount} / {syncEditor.rows.length} stamped</span>
<button class="btn" onclick={save} disabled={!allStamped || saving}>
{saving ? "Saving…" : "Save timing"}
</button>
<button class="close" title={confirmDiscard ? "Discard timing?" : "Close (Esc)"} onclick={requestClose}>
{#if confirmDiscard}<span class="discard">Discard?</span>{:else}<Icon name="x" size={17} />{/if}
</button>
</div>
</header>
{#if saveError}
<div class="warnbar">
<Icon name="alert" size={13} />
{saveError}
</div>
{/if}
{#if wrongTrack}
<div class="warnbar">
<Icon name="alert" size={13} />
Playback moved to another track — stamps follow the PLAYING clock. Play
“{syncEditor.identity?.title}” again to keep tapping.
</div>
{/if}
<div class="stripwrap" aria-hidden="true">
{#if strip.length}
<svg class="strip" preserveAspectRatio="none" viewBox="0 0 {strip.length} 40">
{#each strip as v, i (i)}
<rect x={i} y={40 - v * 38} width="0.85" height={v * 38} rx="0.3" />
{/each}
{#each syncEditor.rows as row (row)}
{#if row.time_ms != null && totalMs}
<rect
class="mark"
x={(row.time_ms / totalMs) * strip.length}
y="0"
width="0.28"
height="40"
/>
{/if}
{/each}
</svg>
<span class="playhead" style="left: {playheadPct}%"></span>
{:else if activityFailed}
<p class="strip-note">
{syncEditor.itemPath
? "No usable vocal-band evidence for the estimate — tap by hand."
: "Streamed track — the activity strip and estimate need a local file."}
</p>
{:else}
<p class="strip-note">Reading the vocal band…</p>
{/if}
</div>
<div class="controls">
<button class="tbtn" title="Restart the song" onclick={restartSong}>
<Icon name="skip-back" size={14} />
</button>
<button class="tbtn" title="Back 5 seconds" onclick={back5}>−5s</button>
<button class="tbtn main" title="Play / pause" onclick={togglePlay}>
<Icon name={player.status === "playing" ? "pause" : "play"} size={16} />
</button>
<span class="clock">{fmt((player.position ?? 0) * 1000)}</span>
<span class="spacer"></span>
<button
class="tbtn wide"
title="Estimate rough times from the vocal band, then correct by tapping"
onclick={roughFill}
disabled={!activity || roughBusy}
>
Rough-fill (estimated)
</button>
<label class="ripple">
<input type="checkbox" bind:checked={ripple} />
ripple ± to later lines
</label>
</div>
<p class="hint">
Press <span class="key">Space</span> (or click the line) as each line begins ·
<span class="key">Backspace</span> re-arms the previous line
</p>
<div class="rows" bind:this={listEl} use:floatScroll>
{#each syncEditor.rows as row, index (index)}
<div
class="row"
class:next={index === cursor}
class:stamped={row.time_ms != null}
data-row={index}
>
<button
class="stamp"
title={row.time_ms != null ? "Re-stamp at the playhead" : "Stamp at the playhead"}
onclick={() => stampRow(index)}
>
{fmt(row.time_ms)}
</button>
<span class="text">{row.text}</span>
{#if row.time_ms != null}
<span class="adj">
<button class="abtn" title="Earlier (−0.25s)" onclick={() => adjustRow(index, -250)}>−</button>
<button class="abtn" title="Later (+0.25s)" onclick={() => adjustRow(index, 250)}>+</button>
</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<style>
.room {
position: fixed;
inset: 0;
z-index: 95;
display: flex;
flex-direction: column;
background:
radial-gradient(120% 80% at 15% 0%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 55%),
var(--bg-base);
padding: 22px 34px 18px;
}
header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
flex: none;
}
.eyebrow {
font-size: 10.5px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--accent-hover);
}
.t {
font-size: 20px;
font-weight: 800;
margin-top: 2px;
}
.a {
font-size: 13px;
color: var(--text-secondary);
margin-top: 1px;
}
.head-actions {
display: flex;
align-items: center;
gap: 12px;
flex: none;
}
.count {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.btn {
padding: 9px 18px;
border-radius: 18px;
background: var(--accent);
color: #000;
font-size: 13px;
font-weight: 800;
}
.btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.btn:disabled {
opacity: 0.45;
}
.close {
display: grid;
place-items: center;
min-width: 36px;
height: 36px;
border-radius: 18px;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.06);
padding: 0 10px;
}
.close:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.12);
}
.discard {
font-size: 12px;
font-weight: 800;
color: #f15e6c;
}
.warnbar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
padding: 8px 13px;
border-radius: 9px;
background: rgba(232, 163, 61, 0.13);
color: #e8a33d;
font-size: 12.5px;
font-weight: 700;
flex: none;
}
.stripwrap {
position: relative;
height: 52px;
margin-top: 14px;
border-radius: 8px;
background: var(--bg-elevated);
overflow: hidden;
flex: none;
}
.strip {
position: absolute;
inset: 4px 6px;
width: calc(100% - 12px);
height: calc(100% - 8px);
}
.strip rect {
fill: color-mix(in srgb, var(--accent) 55%, transparent);
}
.strip rect.mark {
fill: #fff;
opacity: 0.85;
}
.playhead {
position: absolute;
top: 0;
bottom: 0;
width: 1.5px;
background: #fff;
opacity: 0.9;
}
.strip-note {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 12px;
color: var(--text-muted);
}
.controls {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
flex: none;
}
.tbtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-width: 38px;
height: 34px;
padding: 0 12px;
border-radius: 17px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 12.5px;
font-weight: 700;
}
.tbtn:hover:not(:disabled) {
background: var(--bg-press);
color: var(--text-primary);
}
.tbtn:disabled {
opacity: 0.45;
}
.tbtn.main {
background: var(--accent);
color: #000;
}
.tbtn.main:hover {
background: var(--accent-hover);
}
.clock {
font-size: 12.5px;
font-weight: 700;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
min-width: 44px;
}
.spacer {
flex: 1;
}
.ripple {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
}
.ripple input {
accent-color: var(--accent);
}
.hint {
margin-top: 10px;
font-size: 12px;
color: var(--text-muted);
flex: none;
}
.key {
display: inline-block;
padding: 1px 7px;
border-radius: 5px;
background: var(--bg-highlight);
border: 1px solid var(--border);
font-size: 11px;
font-weight: 700;
color: var(--text-secondary);
}
.rows {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-top: 10px;
padding-right: 8px;
}
.row {
display: flex;
align-items: center;
gap: 14px;
padding: 7px 10px;
border-radius: 8px;
transition: background 0.15s;
}
.row.next {
background: var(--accent-soft);
}
.row.next .text {
color: var(--text-primary);
font-weight: 800;
}
.stamp {
flex: none;
width: 64px;
text-align: center;
padding: 4px 0;
border-radius: 7px;
background: var(--bg-highlight);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.row.stamped .stamp {
background: var(--accent-soft);
color: var(--accent-hover);
}
.stamp:hover {
background: var(--bg-press);
color: var(--text-primary);
}
.text {
flex: 1;
min-width: 0;
font-size: 15.5px;
font-weight: 600;
color: var(--text-secondary);
line-height: 1.45;
}
.row.stamped .text {
color: var(--text-primary);
}
.adj {
display: none;
gap: 4px;
flex: none;
}
.row:hover .adj {
display: inline-flex;
}
.abtn {
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 7px;
background: var(--bg-highlight);
color: var(--text-secondary);
font-size: 14px;
font-weight: 800;
}
.abtn:hover {
background: var(--bg-press);
color: var(--text-primary);
}
</style>