From 9768072193eda7416fdfc71e1de52697a11b9ed5 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:56:55 +0100 Subject: [PATCH 1/2] feat(player): exact timestamp input for loop start/end (#246) Add two editable timestamp fields in the transport footer for setting the loop region precisely, alongside the existing drag/click select. Fields display mm:ss.mmm and accept either mm:ss.mmm or plain decimal seconds. - utils.js: fmtTimeMs (integer-ms math, no rounding carry) and parseTimecode (mm:ss.mmm or plain seconds, null on invalid). - transport.js: syncLoopInputs keeps the fields in sync on drag/toggle (never clobbering a field being edited, disabled when no track loaded); commitLoopInput parses, clamps to [0, totalDuration], enforces the MIN_LOOP_SEC ordering, then updates the loop via the existing setters + updateLoopRegionVisual. Enter/blur commit, Escape reverts. Invalid input reverts the field in place (showError belongs to the import form). - player.js: refresh loop UI on track load so the inputs enable + reset once the duration is known. Values flow through the existing loopStart/loopEnd setters and audioEngine.setLoop, so the model and engine are unchanged. --- static/css/daw.css | 22 +++++++++++++ static/index.html | 6 ++++ static/js/player.js | 3 ++ static/js/state.js | 2 ++ static/js/transport.js | 70 +++++++++++++++++++++++++++++++++++++++++- static/js/utils.js | 23 ++++++++++++++ 6 files changed, 125 insertions(+), 1 deletion(-) diff --git a/static/css/daw.css b/static/css/daw.css index 69488dd6..120c8539 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -1930,6 +1930,28 @@ input, textarea { font-family: inherit; } } .footer-time-sep { font-size: 13px; color: var(--muted); } +/* Exact loop start/end inputs */ +.footer-loop-times { + display: flex; align-items: center; gap: 6px; + justify-content: center; margin-top: 4px; + cursor: default; user-select: none; +} +.loop-times-label { + font-size: 10px; font-weight: 700; letter-spacing: 0.08em; + text-transform: uppercase; color: var(--fg-2); flex-shrink: 0; +} +.loop-times-sep { font-size: 12px; color: var(--muted); flex-shrink: 0; } +.loop-time-input { + width: 8ch; box-sizing: content-box; + padding: 2px 6px; + background: var(--panel); border: 1px solid var(--border-strong); + border-radius: 5px; outline: none; + color: var(--fg); font-family: inherit; font-size: 12px; font-weight: 600; + font-variant-numeric: tabular-nums; text-align: center; +} +.loop-time-input:focus { border-color: var(--accent); } +.loop-time-input:disabled { opacity: 0.45; cursor: not-allowed; } + /* Transport buttons */ .footer-transport { display: flex; align-items: center; gap: 8px; flex-shrink: 0; diff --git a/static/index.html b/static/index.html index 7c912eee..7d64d8e0 100644 --- a/static/index.html +++ b/static/index.html @@ -581,6 +581,12 @@ / 0:00 +
TEMPO diff --git a/static/js/player.js b/static/js/player.js index 77423866..4407ee1c 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -800,6 +800,9 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti setLoopEnd(0); loopBtn.classList.remove("active"); loopRegionEl.classList.add("hidden"); + // Refresh loop UI so the exact-loop inputs enable + reset to 00:00.000 now + // that the track duration is known. + updateLoopRegionVisual(); // User-selected stems only. Backend produced all 6, but the import- // page toggles tell us which ones the user actually wanted to see. diff --git a/static/js/state.js b/static/js/state.js index 025487be..c03efd2b 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -43,6 +43,8 @@ export const presenceRulerEl = $("presence-ruler"); export const presencePlayheadEl = $("presence-playhead"); export const footerTimeElapsed = $("footer-time-elapsed"); export const footerTimeTotal = $("footer-time-total"); +export const loopStartInput = $("t-loop-start"); +export const loopEndInput = $("t-loop-end"); export const stemListEl = document.querySelector(".stem-list"); export const npScrubEl = document.querySelector(".np-scrub"); export const npScrubFill = $("footer-scrub-fill"); diff --git a/static/js/transport.js b/static/js/transport.js index bd05c754..702c8bc6 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -1,4 +1,4 @@ -import { fmtTime, fmtTickLabel } from "./utils.js"; +import { fmtTime, fmtTickLabel, fmtTimeMs, parseTimecode } from "./utils.js"; import { playBtn, playMiniBtn, stopBtn, loopBtn, timeEl, masterFader, speedEl, speedLabelEl, @@ -7,6 +7,7 @@ import { waveScroll, waveCanvas, multitrackContainer, presenceRulerEl, presencePlayheadEl, footerTimeElapsed, footerTimeTotal, npScrubFill, footerWaveDrawFn, + loopStartInput, loopEndInput, setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume, setPlaybackSpeed, } from "./state.js"; import { applyMix } from "./mixer.js"; @@ -150,6 +151,8 @@ export function updateLoopRegionVisual() { // Keep the engine's loop bounds in sync with every loop change (toggle/drag); // the engine wraps playback itself off these values. No-op on streaming path. audioEngine?.setLoop(loopEnabled, loopStart, loopEnd); + // Mirror the bounds into the exact-loop text fields (skips fields being edited). + syncLoopInputs(); if (!loopEnabled || !totalDuration) { loopRegionEl.classList.add("hidden"); return; @@ -166,6 +169,70 @@ export function updateLoopRegionVisual() { loopRegionEl.classList.remove("hidden"); } +// Keep the exact-loop text fields in sync with loopStart/loopEnd after any +// programmatic change (drag, toggle). Never overwrite a field the user is +// actively editing, and disable both when no track is loaded. +function syncLoopInputs() { + const enabled = totalDuration > 0; + for (const [input, value] of [ + [loopStartInput, loopStart], + [loopEndInput, loopEnd], + ]) { + if (!input) continue; + input.disabled = !enabled; + if (document.activeElement !== input) input.value = fmtTimeMs(value); + } +} + +// Commit a typed loop time. Invalid/out-of-range input reverts the field to the +// current stored value (self-evident rejection) rather than raising an error; +// showError lives in the import form and would surface in the wrong place. +function commitLoopInput(which) { + const input = which === "start" ? loopStartInput : loopEndInput; + if (!input) return; + const revert = () => { + input.value = fmtTimeMs(which === "start" ? loopStart : loopEnd); + }; + const parsed = parseTimecode(input.value); + if (parsed === null || totalDuration <= 0) { + revert(); + return; + } + const v = Math.max(0, Math.min(totalDuration, parsed)); + const start = which === "start" ? v : loopStart; + const end = which === "end" ? v : loopEnd; + if (end - start < MIN_LOOP_SEC) { + revert(); + return; + } + setLoopStart(start); + setLoopEnd(end); + setLoopEnabled(true); + loopBtn.classList.add("active"); + updateLoopRegionVisual(); +} + +function wireLoopInputs() { + for (const [input, which] of [ + [loopStartInput, "start"], + [loopEndInput, "end"], + ]) { + if (!input) continue; + input.addEventListener("blur", () => commitLoopInput(which)); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + input.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + input.value = fmtTimeMs(which === "start" ? loopStart : loopEnd); + input.blur(); + } + }); + } + syncLoopInputs(); +} + // Standard DAW transport state machine: // [stopped] (paused at start) ─Play→ [playing] // ↑ ↓ Play @@ -420,6 +487,7 @@ export function wireTransportButtons() { stopBtn.addEventListener("click", stopTransport); loopBtn.addEventListener("click", toggleLoop); wireLoopDrag(); + wireLoopInputs(); wireZoomButtons(); wireLaneScrollSync(); masterFader?.addEventListener("input", () => { diff --git a/static/js/utils.js b/static/js/utils.js index 40903824..40bbe710 100644 --- a/static/js/utils.js +++ b/static/js/utils.js @@ -109,4 +109,27 @@ export function fmtTickLabel(s) { return `${m}:${sec}`; } +// Millisecond-precise timecode "mm:ss.mmm" for the exact-loop inputs. Integer-ms +// math avoids a rounding carry bug (e.g. 0.9999s -> "00:01.000", not "00:00.1000"). +export function fmtTimeMs(s) { + if (!isFinite(s) || s < 0) return "00:00.000"; + const totalMs = Math.round(s * 1000); + const m = Math.floor(totalMs / 60000); + const sec = Math.floor((totalMs % 60000) / 1000); + const ms = totalMs % 1000; + return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}.${ms + .toString() + .padStart(3, "0")}`; +} + +// Parse a user-typed loop time. Accepts "mm:ss(.mmm)" (seconds field 0-59) or a +// plain decimal-seconds value ("12.48"). Returns seconds, or null if unparseable. +export function parseTimecode(str) { + const t = String(str ?? "").trim(); + const colon = /^(\d+):([0-5]?\d(?:\.\d{1,3})?)$/.exec(t); + if (colon) return parseInt(colon[1], 10) * 60 + parseFloat(colon[2]); + if (/^\d+(?:\.\d+)?$/.test(t)) return parseFloat(t); + return null; +} + export const $ = (id) => document.getElementById(id); \ No newline at end of file From 0ba022b24c6f0cb49c948c323fbcea9d913964e2 Mon Sep 17 00:00:00 2001 From: Thales Pereira <31625914+thcp@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:26:23 +0100 Subject: [PATCH 2/2] fix(player): place loop time inputs right of the loop button Move the exact loop start/end fields inline into .footer-transport, directly after the loop button, instead of a separate row below the time readout. Drop the redundant LOOP label now that the fields sit next to the loop control. --- static/css/daw.css | 11 +++-------- static/index.html | 11 +++++------ 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/static/css/daw.css b/static/css/daw.css index 120c8539..bf2e29d5 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -1930,15 +1930,10 @@ input, textarea { font-family: inherit; } } .footer-time-sep { font-size: 13px; color: var(--muted); } -/* Exact loop start/end inputs */ +/* Exact loop start/end inputs (inline, right of the loop button) */ .footer-loop-times { - display: flex; align-items: center; gap: 6px; - justify-content: center; margin-top: 4px; - cursor: default; user-select: none; -} -.loop-times-label { - font-size: 10px; font-weight: 700; letter-spacing: 0.08em; - text-transform: uppercase; color: var(--fg-2); flex-shrink: 0; + display: flex; align-items: center; gap: 5px; + margin-left: 4px; cursor: default; user-select: none; } .loop-times-sep { font-size: 12px; color: var(--muted); flex-shrink: 0; } .loop-time-input { diff --git a/static/index.html b/static/index.html index 7d64d8e0..836d74f6 100644 --- a/static/index.html +++ b/static/index.html @@ -575,18 +575,17 @@ +
-
TEMPO