634 lines
20 KiB
Svelte
634 lines
20 KiB
Svelte
<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>
|