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 @@
+
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 @@
+
-