Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -1930,6 +1930,23 @@ input, textarea { font-family: inherit; }
}
.footer-time-sep { font-size: 13px; color: var(--muted); }

/* Exact loop start/end inputs (inline, right of the loop button) */
.footer-loop-times {
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 {
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;
Expand Down
5 changes: 5 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,11 @@
<path d="M17 2l4 4-4 4 M3 12V8a2 2 0 0 1 2-2h16 M7 22l-4-4 4-4 M21 12v4a2 2 0 0 1-2 2H3"/>
</svg>
</button>
<div class="footer-loop-times" title="Exact loop start / end (mm:ss.mmm or seconds)">
<input type="text" id="t-loop-start" class="loop-time-input num" inputmode="decimal" spellcheck="false" autocomplete="off" aria-label="Loop start" value="00:00.000">
<span class="loop-times-sep" aria-hidden="true">-</span>
<input type="text" id="t-loop-end" class="loop-time-input num" inputmode="decimal" spellcheck="false" autocomplete="off" aria-label="Loop end" value="00:00.000">
</div>
</div>
<div class="footer-time-row">
<span class="num footer-elapsed" id="footer-time-elapsed">0:00</span>
Expand Down
3 changes: 3 additions & 0 deletions static/js/player.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions static/js/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
70 changes: 69 additions & 1 deletion static/js/transport.js
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -420,6 +487,7 @@ export function wireTransportButtons() {
stopBtn.addEventListener("click", stopTransport);
loopBtn.addEventListener("click", toggleLoop);
wireLoopDrag();
wireLoopInputs();
wireZoomButtons();
wireLaneScrollSync();
masterFader?.addEventListener("input", () => {
Expand Down
23 changes: 23 additions & 0 deletions static/js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);