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
7 changes: 7 additions & 0 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -2877,6 +2877,13 @@ input, textarea { font-family: inherit; }
background: rgba(232,200,64,0.18); border-color: rgba(232,200,64,0.55); color: var(--accent);
}
.click-metro-icon { flex-shrink: 0; }
/* The on/off toggle is the icon alone (#681), square like the volume button
beside it. The Grid button shares the class above and keeps its label. */
.click-toggle-btn.click-toggle-icon {
width: 34px;
padding: 0;
justify-content: center;
}

/* Beat grid editor: canvas overlay + toolbar */
.beatgrid-canvas {
Expand Down
7 changes: 5 additions & 2 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -851,11 +851,14 @@
<div class="footer-group footer-group-click" id="t-metro-wrap">
<span class="footer-group-label" data-i18n="click.group">Click track</span>
<div class="footer-group-body">
<button class="click-toggle-btn" id="t-metro" type="button" title="Click track (K)" aria-label="Click track" data-i18n-title="click.toggleTitle" data-i18n-aria-label="click.toggleAria" aria-pressed="false" disabled>
<!-- The icon alone (#681). It used to carry the word ON, which never
changed, so with the click off it still said ON. The gold
highlight is the state; the name is in the aria-label and the
tooltip. -->
<button class="click-toggle-btn click-toggle-icon" id="t-metro" type="button" title="Click track (K)" aria-label="Click track" data-i18n-title="click.toggleTitle" data-i18n-aria-label="click.toggleAria" aria-pressed="false" disabled>
<svg class="click-metro-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M9 3h6l4 18H5z"/><path d="M15.5 9 8 15"/>
</svg>
<span data-i18n="click.on">ON</span>
</button>
<!-- Beside the on/off pill rather than among the options: how
loud the click is belongs with whether it is running, and it
Expand Down
8 changes: 7 additions & 1 deletion static/js/audioEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,13 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
// ctx time; the count-in clicks (negative source time) map into `[now, when]`
// through the same sourceTimeToCtxTime the metronome uses, so they stay locked
// to the audio. See transport.togglePlayPause + metronome.playCountIn.
//
// Resolves true once that mapping describes this start, false if nothing was
// started. Here that is always before play() returns, but the streaming
// engine can only say so after a fetch, and callers are written against the
// promise so the two engines keep one contract.
function play(leadIn = 0) {
if (playing || destroyed || !tracks.size) return;
if (playing || destroyed || !tracks.size) return Promise.resolve(false);
// Safari: resume the context fire-and-forget within the user-gesture tick.
if (ctx.state === "suspended") ctx.resume().catch(() => {});
let off = startOffset;
Expand All @@ -304,6 +309,7 @@ export function createAudioEngine(stems, { onTime, onEnded, context } = {}) {
startSources(off, when);
playing = true;
tickLoop.schedule();
return Promise.resolve(true);
}

function pause() {
Expand Down
11 changes: 10 additions & 1 deletion static/js/beatgridUi.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
undoBeatGrid, redoBeatGrid, resetBeatGrid, canUndo, canRedo,
barLengthAt, setBarLengthAt, getBeats,
} from "./beatgrid.js";
import { applyMetronomeAccent } from "./transport.js";
import { applyMetronomeAccent, toggleMetronome, onMetronomeToggle } from "./transport.js";

const TOOL_BTNS = [
["bg-tool-move", "move", "Drag a beat to retime the region. Alt-drag moves one beat."],
Expand Down Expand Up @@ -56,6 +56,12 @@ export function toggleBeatGridEditor(force) {
if (!bgToolbar) return;
const next = force === undefined ? !isBeatGridEditing() : !!force;
if (next && !_available) return;
// The editor is a click-track tool: it edits the beats the click plays, and
// the way to check an edit is to hear it. So opening it switches the click
// on, the same way choosing a vocal mode switches vocals on, and switching
// the click off closes it (see wireBeatGridUi). Otherwise Grid could stay
// lit inside the panel of a feature that is off (#655).
if (next) toggleMetronome(true);
setBeatGridEditing(next);
bgToolbar.classList.toggle("hidden", !next);
// The Grid button is a press-to-open toggle like the click and count-in
Expand Down Expand Up @@ -101,6 +107,9 @@ export function wireBeatGridUi() {
});

metroEditBtn?.addEventListener("click", () => toggleBeatGridEditor());
onMetronomeToggle((on) => {
if (!on && isBeatGridEditing()) toggleBeatGridEditor(false);
});

bgUndoBtn?.addEventListener("click", () => { undoBeatGrid(); syncBeatGridButtons(); });
bgRedoBtn?.addEventListener("click", () => { redoBeatGrid(); syncBeatGridButtons(); });
Expand Down
41 changes: 30 additions & 11 deletions static/js/chunkedAudioEngine.js
Original file line number Diff line number Diff line change
Expand Up @@ -644,9 +644,27 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {
// (see the sync/async branch below); by the time count-in can even be
// armed the track has been loaded long enough that chunk 0 is virtually
// always already cached.
//
// That last assumption held for the first play and not after it. Chunks
// behind the playhead are evicted, so Stop and play again goes back to a
// chunk that is gone and takes the fetch path, where the mapping is only
// set once the fetch lands. The count-in was being scheduled straight after
// play() returned, against the previous start's mapping, which put every
// click in the past, and Web Audio drops those silently (#655).
//
// So play() resolves true once sourceTimeToCtxTime describes this start,
// and false if the start was abandoned (paused first, or the fetch failed).
// The count-in waits for it, and a count-in lead is honoured on either path.
function play(leadIn = 0) {
if (playing || destroyed) return;
if (playing || destroyed) return Promise.resolve(false);
if (ctx.state === "suspended") ctx.resume().catch(() => {});
// Play from the end starts again from the top, as the buffered engine's
// play() does. The transport places a count-in on that assumption; without
// this the count-in led into 0 while the audio started at the end.
if (_startOffset >= _duration) {
_startOffset = 0;
_scheduledTo = 0;
}
playing = true;

const chunkIdx = Math.floor(_startOffset / CHUNK_SEC);
Expand All @@ -658,7 +676,7 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {
// keeps 50 ms headroom since a fetch/decode just finished. A count-in's
// lead overrides either when it asks for more room than that.
const startWith = (buffers, lead) => {
if (!playing || destroyed) return;
if (!playing || destroyed) return false;
_resetProcessor();
const when = ctx.currentTime + Math.max(lead, countInLead);
_startCtxTime = when;
Expand All @@ -668,21 +686,22 @@ export function createChunkedAudioEngine(stems, { onTime, onEnded, context } = {
_epoch++; // mapping is valid from here; see isClockReady
_fetchChunk(chunkIdx + 1); // pre-fetch next chunk
tickLoop.schedule();
return true;
};

// chunk 0 is pre-decoded during ready(), so the sync path is the hot path.
const hit = _cache.get(chunkIdx);
if (hit?.result) {
startWith(hit.result, 0.01);
} else {
_fetchChunk(chunkIdx)
.then((buffers) => startWith(buffers, 0.05))
.catch((e) => {
console.warn("[chunked] play fetch failed:", e);
playing = false;
_audioStarted = false;
});
return Promise.resolve(startWith(hit.result, 0.01));
}
return _fetchChunk(chunkIdx)
.then((buffers) => startWith(buffers, 0.05))
.catch((e) => {
console.warn("[chunked] play fetch failed:", e);
playing = false;
_audioStarted = false;
return false;
});
}

function pause() {
Expand Down
10 changes: 0 additions & 10 deletions static/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,6 @@ const en = {
"click.toggleTitle": "Click track (K)",
"click.toggleAria": "Click track",
"click.unavailableAria": "Click track unavailable",
"click.on": "ON",
"click.optionsAria": "Click track options",
"click.countIn": "Count-in",
"click.countInTitle": "Bars of click before the song starts when you press play",
Expand Down Expand Up @@ -1052,7 +1051,6 @@ const pl = {
"click.toggleTitle": "Metronom (K)",
"click.toggleAria": "Metronom",
"click.unavailableAria": "Metronom niedostępny",
"click.on": "WŁ.",
"click.optionsAria": "Opcje metronomu",
"click.countIn": "Odliczanie",
"click.countInTitle": "Takty metronomu przed rozpoczęciem utworu po naciśnięciu odtwarzania",
Expand Down Expand Up @@ -1660,7 +1658,6 @@ const ja = {
"click.toggleTitle": "クリックトラック (K)",
"click.toggleAria": "クリックトラック",
"click.unavailableAria": "クリックトラック利用不可",
"click.on": "オン",
"click.optionsAria": "クリックトラックオプション",
"click.countIn": "カウントイン",
"click.countInTitle": "再生を押すと曲が始まる前に鳴らすクリックの小節数",
Expand Down Expand Up @@ -2244,7 +2241,6 @@ const zhHans = {
"click.toggleTitle": "节拍器 (K)",
"click.toggleAria": "节拍器",
"click.unavailableAria": "节拍器不可用",
"click.on": "开启",
"click.optionsAria": "节拍器选项",
"click.countIn": "预备拍",
"click.countInTitle": "按下播放后,在歌曲开始前的节拍小节数",
Expand Down Expand Up @@ -2829,7 +2825,6 @@ const de = {
"click.toggleTitle": "Click-Track (K)",
"click.toggleAria": "Click-Track",
"click.unavailableAria": "Click-Track nicht verfügbar",
"click.on": "EIN",
"click.optionsAria": "Click-Track-Optionen",
"click.countIn": "Vorzähler",
"click.countInTitle": "Takte Click, bevor der Song bei Wiedergabe beginnt",
Expand Down Expand Up @@ -3424,7 +3419,6 @@ const pt = {
"click.toggleTitle": "Clique de referência (K)",
"click.toggleAria": "Clique de referência",
"click.unavailableAria": "Clique de referência indisponível",
"click.on": "LIGADO",
"click.optionsAria": "Opções do clique de referência",
"click.countIn": "Contagem",
"click.countInTitle": "Compassos de clique antes de a música começar ao pressionar reproduzir",
Expand Down Expand Up @@ -4020,7 +4014,6 @@ const id = {
"click.toggleTitle": "Click track (K)",
"click.toggleAria": "Click track",
"click.unavailableAria": "Click track tidak tersedia",
"click.on": "AKTIF",
"click.optionsAria": "Opsi click track",
"click.countIn": "Hitungan awal",
"click.countInTitle": "Birama klik sebelum lagu dimulai saat menekan putar",
Expand Down Expand Up @@ -4605,7 +4598,6 @@ const fr = {
"click.toggleTitle": "Métronome (K)",
"click.toggleAria": "Métronome",
"click.unavailableAria": "Métronome indisponible",
"click.on": "ACTIVÉ",
"click.optionsAria": "Options du métronome",
"click.countIn": "Décompte",
"click.countInTitle": "Mesures de décompte avant le début du morceau lors de la lecture",
Expand Down Expand Up @@ -5319,7 +5311,6 @@ const es = {
"click.toggleTitle": "Click (K)",
"click.toggleAria": "Click",
"click.unavailableAria": "Click no disponible",
"click.on": "SÍ",
"click.optionsAria": "Opciones del click",
"click.countIn": "Cuenta de entrada",
"click.countInTitle": "Compases de click antes de que empiece la canción al pulsar reproducir",
Expand Down Expand Up @@ -5936,7 +5927,6 @@ const ko = {
"click.toggleTitle": "클릭 트랙 (K)",
"click.toggleAria": "클릭 트랙",
"click.unavailableAria": "클릭 트랙을 쓸 수 없음",
"click.on": "켜기",
"click.optionsAria": "클릭 트랙 옵션",
"click.countIn": "카운트인",
"click.countInTitle": "재생을 누르면 곡이 시작하기 전에 울릴 클릭 마디 수",
Expand Down
52 changes: 47 additions & 5 deletions static/js/transport.js
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,15 @@ function _currentGrid() {
return null;
}

// Bumped by every pause and stop, so a count-in still waiting for its start
// to be scheduled knows it has been overtaken and does not sound afterwards.
let _countInGeneration = 0;

function _dropCountIn() {
_countInGeneration++;
metronome?.cancelCountIn?.();
}

// Arm a count-in when it is enabled and the engine + grid can support one.
// Starts the audio late (engine.play(leadIn)) and schedules the count clicks in
// the gap, whether or not the running click is on. Returns true when it took
Expand All @@ -510,8 +519,21 @@ function _armCountIn(eng, startPos) {
// Clicks sit in source time, leading into the start position: the last lands
// one beat before the audio, so the song enters on the next downbeat.
const sourceClicks = clicks.map((c) => ({ time: startPos - leadIn + c.offset, accent: c.accent }));
eng.play(leadIn); // sets the (future) clock the clicks are scheduled against
metronome.playCountIn(sourceClicks);
// The clicks can only be placed once the engine's clock describes this
// start, and on the streaming engine that can be after a fetch. Scheduling
// them as soon as play() returned used the previous start's clock and put
// every one in the past, so only the first play of a track ever counted in
// (#655). Wait for the start, and drop the count-in if a pause or stop got
// there first.
// The metronome is held as it is now: if the track changes while the start
// is still waiting, the live binding points at the next track's click, and
// this one's clicks belong to this one. A destroyed metronome ignores them.
const generation = ++_countInGeneration;
const clickTrack = metronome;
eng.play(leadIn).then((started) => {
if (!started || generation !== _countInGeneration) return;
clickTrack.playCountIn(sourceClicks);
});
return true;
}

Expand All @@ -521,7 +543,7 @@ export function togglePlayPause() {
if (!tx) return;
if (tx.isPlaying()) {
tx.pause();
metronome?.cancelCountIn?.(); // drop a count-in if paused before the audio enters
_dropCountIn(); // drop a count-in if paused before the audio enters
// The engine emits no play/pause events (the multitrack stays silent), so
// the play-button visual that the ws "pause" handler normally toggles must
// be driven here directly.
Expand Down Expand Up @@ -557,7 +579,7 @@ export function stopTransport() {
const tx = eng ?? multitrack;
if (!tx) return;
tx.pause();
metronome?.cancelCountIn?.(); // a count-in in progress must not outlive Stop
_dropCountIn(); // a count-in in progress must not outlive Stop
tx.setTime(loopEnabled ? loopStart : 0); // engine: setTime → onTime → stop visual
if (eng) playBtn.classList.remove("playing");
}
Expand Down Expand Up @@ -1238,10 +1260,21 @@ function _renderCountIn() {
if (!metroCountInEl) return;
metroCountInEl.value = String(metronomeCountInBars);
// The wrap carries the "on" tint the toggle button used to, so the panel
// still shows at a glance that a count-in is armed.
// still shows at a glance that a count-in is armed. Armed whether or not the
// running click is on: the count-in plays on its own (see _armCountIn).
metroCountInEl.parentElement?.classList.toggle("active", metronomeCountInBars > 0);
}

// Things that have to follow the click on and off but live in modules this
// one cannot import without a cycle: the beat grid editor imports from here.
const _metronomeListeners = new Set();

/** Calls `fn(on)` whenever the click track is switched on or off. */
export function onMetronomeToggle(fn) {
_metronomeListeners.add(fn);
return () => _metronomeListeners.delete(fn);
}

export function toggleMetronome(force) {
if (!metroBtn || metroBtn.disabled) return;
const on = force === undefined ? !metronomeEnabled : !!force;
Expand All @@ -1250,6 +1283,15 @@ export function toggleMetronome(force) {
metroBtn.setAttribute("aria-pressed", on ? "true" : "false");
metronome?.setEnabled(on);
_saveMetroPrefs();
// The grid editor follows the click; see beatgridUi. The count-in does not,
// because it plays with the click off as well as on.
for (const fn of _metronomeListeners) {
try {
fn(on);
} catch (e) {
console.warn("[metronome] toggle listener failed:", e);
}
}
}

/**
Expand Down
Loading
Loading