Files
SoulStream/src/components/LyricsSyncEditor.svelte
T

634 lines
20 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>