playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user