From c3ca54d8f405861dfee2e1d5c93cd359b20289b5 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Fri, 10 Jul 2026 03:28:56 +0200 Subject: [PATCH 1/2] refactor(editor): move the audio subsystem to src/audio.js (R2, step 27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/main.js 8,760 -> 7,715. Twenty-eighth module; the graph stays acyclic. main.js is now 64% below the 21,176 lines this refactor started from. 1,039 lines: the playback engine (startPlayback / stopPlayback / playbackTick), the waveform, the onset strip, follow-scroll and the WebAudio graph, plus the guide claps, the metronome, the A/B reference loop, the per-bus mixer and the edit blip. Stacks on #178 (the two loop-region pures it needs from transport.js). It owns the rAF loop: `rafId` is module-scope, set by playbackTick and cancelled by teardownAudio(), which main.js's screen teardown calls. The import-time button seeding folded into initAudio(), called from init(). The 8 window.editor* toolbar handlers are exported and re-attached. Five main.js symbols arrive as host hooks: draw, drawNow, the scroll-bounds math (editorClampScrollX / editorApplyScrollBounds), and the A/B loop-region selection (selectedLoopRegion / setLoopRegionEnabled). TWO REAL BUGS, both found on review, neither caught by the 90 unit tests: 1. main.js was REASSIGNING the now-imported _abPhase and _abOn bindings (illegal — an import binding is read-only). The A/B state's only writers outside the engine were two disarm sites; they now call the new export _abDisarm(), so all A/B state writes stay inside audio.js. Same live-binding rule as every prior step: the writer couldn't move, so it crosses as a function. 2. setHostHooks() was missing all 5 new hooks — my scripted edit's anchor didn't match and Python didn't complain (the exact trap from #176). So audio.js ran against host.js's inert defaults: playback advanced the cursor without repainting, follow-scroll was identity, A/B saw no region. Codex caught it. verify_audio.py now plays in compose mode (grid-driven, no audio buffer) and counts canvas repaints — with the drawNow hook unwired the cursor advances and the canvas never clears. It fails on exactly bug #2 while all 90 unit tests pass. And a third, latent: teardownAudio() completes what the old inline teardown skipped — it stops the guide/metronome setInterval (module-scope, so it outlived a re-injected screen). Codex flagged the gap; the fix is S.playing=false + _guideTimerSync() + _guideCancelVoices(). Tests: ~11 audio suites retarget their source read from main.js to audio.js and strip the `export` keyword before eval; loop_ab and boot_teardown gained host stubs for the new hooks. Several CJS -> .mjs. node --test 90/90, pytest 248/248, npm run lint 0 errors (6 warnings), Codex clean on three rounds, all 20 headless harnesses pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 20 + src/audio.js | 1125 ++++++++++++++++++++++++++++++ src/host.js | 12 + src/main.js | 1104 +---------------------------- tests/audio_mixer.test.js | 4 +- tests/boot_teardown.test.js | 13 +- tests/compose_transport.test.mjs | 5 +- tests/follow_toggle.test.js | 7 +- tests/guide_clap.test.js | 7 +- tests/keyboard_gutter.test.mjs | 4 +- tests/loop_ab.test.js | 32 +- tests/metronome_click.test.js | 7 +- tests/onset_snap.test.js | 19 +- tests/onset_strip.test.js | 7 +- tests/waveform_peaks.test.js | 2 +- 15 files changed, 1252 insertions(+), 1116 deletions(-) create mode 100644 src/audio.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ad607dea..67c1885c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **The screen teardown left the guide/metronome timer running.** The audio + extraction (below) surfaced it: the old inline teardown cancelled the audio + source and the rAF frame but not the `setInterval` that schedules guide claps, + and that timer is module-scope, so it kept firing after a re-injected editor + screen replaced the old one. `teardownAudio()` now stops it. Latent since the + timer was introduced; found by Codex on review. + ### Changed +- **The audio subsystem now lives in `src/audio.js` (R2, step 27).** 1,039 lines: + the playback engine, the waveform, the onset strip, follow-scroll, and the + WebAudio graph, plus the guide claps, the metronome, the A/B reference loop, + the per-bus mixer and the edit blip. `src/main.js` is down to 7,715 — **64%** + below where this refactor started. + It owns the rAF loop (`rafId`) and exports `teardownAudio()`. Five main.js + symbols arrive as host hooks (`draw`/`drawNow`, the scroll-bounds math, the A/B + loop-region selection). The eight `window.editor*` toolbar handlers are exported + and re-attached; the import-time button seeding became `initAudio()`. + + - **The canvas context menu now lives in `src/context-menu.js` (R2, step 25).** 362 lines: the right-click menu and the prompt dialogs it opens (fret, bend, slide). `src/main.js` is down to 8,786. diff --git a/src/audio.js b/src/audio.js new file mode 100644 index 00000000..b981be28 --- /dev/null +++ b/src/audio.js @@ -0,0 +1,1125 @@ +// ════════════════════════════════════════════════════════════════════ +// Audio, playback, and the guide-clap / metronome / mixer that ride on it. +// +// The playback engine (startPlayback / stopPlayback / playbackTick), the +// waveform, the onset strip, follow-scroll, and the WebAudio graph — plus the +// guide claps (a tick per charted event), the metronome, the A/B reference +// loop, the per-bus mixer, and the edit blip. +// +// It owns the rAF loop: `rafId` is module-scope, set by playbackTick and +// cancelled by teardownAudio(), which main.js's screen teardown calls. +// +// main.js keeps the render (draw / drawNow), the scroll-bounds math, and the +// A/B loop-region selection; those arrive through the shared `host` object. The +// transport clock and loop-region pures come from src/transport.js so the +// recorder, the guide scheduler and this engine cannot drift apart. +// +// The 8 window.editor* toolbar handlers are exported and re-attached by main.js. +// Import-time button-seeding moved into initAudio(), called from init(). +// +// Browser surface: WebAudio (AudioContext), `canvas` (for waveform width), +// ════════════════════════════════════════════════════════════════════ +import { timeOf } from './beats.js'; +import { DPR, canvas } from './canvas.js'; +import { timeToX } from './geometry.js'; +import { host } from './host.js'; +import { midiToFreq } from './keys.js'; +import { _recState } from './midi-record.js'; +import { notes } from './notes.js'; +import { S } from './state.js'; +import { + _composeSongDurationPure, _loopPlaybackRestartTimePure, _normalizeLoopRegionPure, + _transportChartTimePure, +} from './transport.js'; +import { setStatus } from './ui.js'; + +// The rAF handle for the playback loop. Module-scope so playbackTick and +// teardownAudio share it; main.js reaches the cancel through teardownAudio(). +let rafId = null; + +// Lazily create the shared AudioContext. Compose mode never decodes a +// recording (loadAudio is the only other creation site), yet the transport +// clock + metronome/guide voices still need a context to schedule on — make +// one on demand. Call from a user gesture (decode / play) so the browser does +// not hand back a permanently-suspended context. +function _ensureAudioCtx() { + if (!S.audioCtx) { + const Ctor = window.AudioContext || window.webkitAudioContext; + if (!Ctor) return null; // no Web Audio — leave S.audioCtx unset so callers bail + S.audioCtx = new Ctor(); + } + return S.audioCtx; +} + +export async function loadAudio(url) { + if (!url) return; + try { + _ensureAudioCtx(); + const resp = await fetch(url); + const buf = await resp.arrayBuffer(); + S.audioBuffer = await S.audioCtx.decodeAudioData(buf); + S.duration = S.audioBuffer.duration; + // A new recording is loaded — re-arm the hearing-safety fade so it + // applies to this recording too, not just the session's first one. + _mixResetFirstPlay(); + host.editorApplyScrollBounds(); + computeWaveform(); + } catch (e) { + console.error('Audio load error:', e); + } +} + +// Build a high-resolution min / max / RMS cache from one channel of PCM so +// the waveform can render its true (asymmetric) shape and stay sharp when +// zoomed in: `min`/`max` are the signed sample extremes per bin (the peak +// envelope), `rms` is the per-bin loudness (the body). Pure — channel data +// in, typed arrays out — so it's unit-testable. `bins` is the entry count. +function _buildWaveformPeaks(data, binSamples) { + const bins = Math.max(1, Math.floor(data.length / binSamples)); + const min = new Float32Array(bins); + const max = new Float32Array(bins); + const rms = new Float32Array(bins); + for (let b = 0; b < bins; b++) { + const start = b * binSamples; + // The last bin soaks up any remainder so no tail samples are dropped. + const end = (b === bins - 1) ? data.length : start + binSamples; + let lo = Infinity, hi = -Infinity, sumSq = 0, cnt = 0; + for (let s = start; s < end; s++) { + const v = data[s]; + if (v < lo) lo = v; + if (v > hi) hi = v; + sumSq += v * v; + cnt++; + } + min[b] = cnt ? lo : 0; + max[b] = cnt ? hi : 0; + rms[b] = cnt ? Math.sqrt(sumSq / cnt) : 0; + } + return { min, max, rms, bins }; +} + +export function computeWaveform() { + if (!S.audioBuffer) return; + const data = S.audioBuffer.getChannelData(0); + // ~3 ms per bin: fine enough that each pixel covers ≥1 bin even at high + // zoom, yet bounded (≈1 MB of typed arrays for a 5-minute song). + const binSamples = Math.max(64, Math.round(S.audioBuffer.sampleRate * 0.003)); + S.waveformPeaks = _buildWaveformPeaks(data, binSamples); + // New audio ⇒ any cached onset analysis is stale. + _onsetCache = null; +} + +/* @pure:onset-strip:start */ +// Transient/onset estimation from the waveform RMS cache — a cheap +// client-side "where do events probably live" hint (no server round-trip, +// no DSP deps). An onset fires where the RMS rises sharply above the local +// baseline (the mean of the preceding window), gated by an absolute noise +// floor and a refractory gap so one attack registers once. Returns +// [{t, s}] — time in seconds and a 0..1 strength. +function _onsetTimesFromPeaksPure(rms, binSec, opts) { + if (!rms || !rms.length || !(binSec > 0)) return []; + const o = opts || {}; + const baselineBins = Math.max(2, o.baselineBins || 16); + const ratio = o.ratio || 1.5; + const floorFrac = o.floorFrac || 0.05; + const riseFrac = o.riseFrac || 0.03; + const minGapSec = o.minGapSec || 0.05; + let global = 0; + for (let i = 0; i < rms.length; i++) if (rms[i] > global) global = rms[i]; + if (!(global > 0)) return []; + const floor = global * floorFrac; + const refractory = Math.max(1, Math.round(minGapSec / binSec)); + const out = []; + let sum = 0; + for (let i = 0; i < Math.min(baselineBins, rms.length); i++) sum += rms[i]; + let lastOnset = -Infinity; + for (let i = baselineBins; i < rms.length; i++) { + const base = sum / baselineBins; + const v = rms[i]; + if (v > floor && v > rms[i - 1] + && v > base * ratio && v - base > global * riseFrac + && i - lastOnset >= refractory) { + out.push({ + t: i * binSec, + s: Math.max(0, Math.min(1, (v - base) / global)), + }); + lastOnset = i; + } + // Slide the baseline window. + sum += v - rms[i - baselineBins]; + } + return out; +} +/* @pure:onset-strip:end */ + +/* @pure:onset-snap:start */ +// Nearest-onset snap: given time-sorted onsets [{t,...}], return the onset +// time nearest to `t` when it lies within `tol` seconds, else null (the caller +// falls back to grid snap). Binary-searches the sorted onsets so the hot drag +// path stays O(log n). Guards non-finite t, empty onsets, and tol <= 0. +export function _nearestOnsetTimePure(onsets, t, tol) { + if (!Array.isArray(onsets) || onsets.length === 0) return null; + if (!Number.isFinite(t) || !(tol > 0)) return null; + // First onset with .t >= t. + let lo = 0, hi = onsets.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (onsets[mid].t < t) lo = mid + 1; else hi = mid; + } + // The nearest onset is one of onsets[lo-1] (last before t) / onsets[lo]. + let best = null, bestD = Infinity; + for (let i = lo - 1; i <= lo; i++) { + if (i < 0 || i >= onsets.length) continue; + const o = onsets[i]; + if (!o || !Number.isFinite(o.t)) continue; + const d = Math.abs(o.t - t); + if (d < bestD) { bestD = d; best = o.t; } + } + return bestD <= tol ? best : null; +} +/* @pure:onset-snap:end */ + +// ── Onset strip toggle + lazy cache ────────────────────────────────── +let _onsetCache = null; // [{t, s}] for the CURRENT waveformPeaks +let _onsetStripOn = null; // cached enabled flag; null until first read + +export function _onsetStripEnabled() { + // Cache the flag so the draw path (every frame during playback) doesn't + // hit localStorage synchronously. Seeded once from storage, then kept in + // sync by _editorToggleOnsetStrip. + if (_onsetStripOn === null) { + try { _onsetStripOn = localStorage.getItem('editorOnsetStrip') === '1'; } + catch (_) { _onsetStripOn = false; } + } + return _onsetStripOn; +} + +export function _ensureOnsets() { + if (_onsetCache) return _onsetCache; + const pk = S.waveformPeaks; + const dur = S.duration || 0; + if (!pk || !pk.bins || !pk.rms || dur <= 0) return null; + _onsetCache = _onsetTimesFromPeaksPure(pk.rms, dur / pk.bins); + return _onsetCache; +} + +export function _refreshOnsetBtn() { + const btn = document.getElementById('editor-onset-btn'); + if (!btn) return; + const on = _onsetStripEnabled(); + btn.classList.toggle('bg-accent', on); + btn.classList.toggle('hover:bg-accent-light', on); + btn.classList.toggle('bg-dark-600', !on); + btn.classList.toggle('hover:bg-dark-500', !on); + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); +} + +export function _editorToggleOnsetStrip() { + const next = !_onsetStripEnabled(); + _onsetStripOn = next; + try { localStorage.setItem('editorOnsetStrip', next ? '1' : '0'); } catch (_) {} + _refreshOnsetBtn(); + host.draw(); + setStatus(next + ? 'Onset strip on — amber blocks mark detected attacks in the recording (display only)' + : 'Onset strip off'); + return true; +} +// window.editorToggleOnsetStrip re-attached in main.js + +// ── Snap target: grid ↔ audio onset ────────────────────────────────── +export function _refreshSnapModeBtn() { + const btn = document.getElementById('editor-snapmode-btn'); + if (!btn) return; + const onset = S.snapMode === 'onset'; + btn.textContent = onset ? 'Onset' : 'Grid'; + btn.classList.toggle('bg-accent', onset); + btn.classList.toggle('hover:bg-accent-light', onset); + btn.classList.toggle('bg-dark-600', !onset); + btn.classList.toggle('hover:bg-dark-500', !onset); + btn.setAttribute('aria-pressed', onset ? 'true' : 'false'); +} + +export function _editorToggleSnapMode() { + S.snapMode = S.snapMode === 'onset' ? 'grid' : 'onset'; + try { localStorage.setItem('editorSnapMode', S.snapMode); } catch (_) {} + _refreshSnapModeBtn(); + if (S.snapMode === 'onset') { + const onsets = _ensureOnsets(); + setStatus(onsets && onsets.length + ? 'Snap to onset — placement snaps to the nearest detected attack (falls back to grid when none is near)' + : 'Snap to onset — no transients detected yet (load a recording, turn on Onsets); snapping to grid until then'); + } else { + setStatus('Snap to grid — placement snaps to the tempo-map subdivisions'); + } + return true; +} +// window.editorToggleSnapMode re-attached in main.js + + +export function _startAudioSourceAtCursor() { + S.audioSource = S.audioCtx.createBufferSource(); + S.audioSource.buffer = S.audioBuffer; + // Reference recording stays on a transparent path to destination — its + // mixer fader is a plain gain (unity by default): the guide-clap limiter + // must never color the recording, even when claps are off. Only the + // guide/click voices sum through the limiter (see _ensureMasterBus). + const refGain = _ensureRefGain(); + if (refGain) S.audioSource.connect(refGain); + else S.audioSource.connect(S.audioCtx.destination); + _mixApplyFirstPlayFade(); + S.audioSource.start(0, S.cursorTime); + _anchorTransportAtCursor(); +} + +// Anchor the transport clock at the current cursor: pin wall-time to the +// AudioContext clock and chart-time to cursorTime, so playbackTick can derive +// the cursor from the ctx clock. In buffered mode this rides alongside the +// BufferSource; in compose mode it IS the whole clock (there is no source). +// Every (re)start is a seek from the clap scheduler's perspective, so drop +// already-queued voices and restart the window at the new cursor — otherwise +// claps scheduled before a loop wrap / seek fire at their old positions +// ("ghost claps"). +export function _anchorTransportAtCursor() { + S.playStartWall = S.audioCtx.currentTime; + S.playStartTime = S.cursorTime; + _guideResetSchedule(); +} + +// Resolve compose-mode duration from live state: the grid end via the A1 +// converter (timeOf of the last beat), the last authored event on the active +// surface, and an optional user-set length (S.composeLength). Buffered mode +// never calls this — there S.duration is the recording's own length. +export function _composeSongDuration() { + const userLen = (typeof S.composeLength === 'number') ? S.composeLength : NaN; + const gridEnd = (S.beats && S.beats.length >= 2) + ? timeOf(S.beats, S.beats.length - 1) + : 0; + let contentEnd = 0; + for (const t of _guideSourceTimes()) if (t > contentEnd) contentEnd = t; + return _composeSongDurationPure(gridEnd, contentEnd, userLen); +} + +export function _restartPlaybackAt(t) { + if (S.audioSource) { + try { S.audioSource.stop(); } catch (_) {} + S.audioSource = null; + } + S.cursorTime = Math.max(0, Math.min(S.duration || Infinity, t)); + // Compose mode re-anchors the clock without a BufferSource — the guide/ + // click scheduler is the only sound (charrette §1.7). + if (S.audioBuffer) _startAudioSourceAtCursor(); + else _anchorTransportAtCursor(); +} + +export function startPlayback() { + // Compose mode (no recording) still needs a context — for the transport + // clock and the metronome/guide voices that are its only sound. Make one + // on the play gesture; the decode path is the only other creation site. + _ensureAudioCtx(); + if (!S.audioCtx) return; // no Web Audio available at all + const composing = !S.audioBuffer; + if (composing) { + // No buffer to bound the song: the grid defines its length (§1.7). + S.duration = _composeSongDuration(); + if (!(S.duration > 0)) return; // empty grid + no content — nothing to play + } + if (S.audioCtx.state === 'suspended') S.audioCtx.resume(); + const region = host.selectedLoopRegion(); + if (S.loopEnabled && region && (S.cursorTime < region.startTime || S.cursorTime >= region.endTime)) { + S.cursorTime = region.startTime; + } + if (composing) { + // No reference recording ⇒ no A/B pass to arm; just anchor the clock so + // playbackTick advances the cursor and the guide/click scheduler (the + // only sound here) fires off the grid. + _anchorTransportAtCursor(); + } else { + // Every (re)start — including seeks, which route through here — begins + // an A/B cycle on the RECORDING pass, so the user always hears the real + // thing first from a fresh position. Reset BEFORE the first tick / + // scheduler sync so _guideTick can never schedule a guide pass off a + // stale phase, and so the first-play fade (in _startAudioSourceAtCursor) + // is the last automation written to the ref gain, not clobbered by this. + _abPhase = 'recording'; + _abApplyRefGain(); + _startAudioSourceAtCursor(); + } + S.playing = true; + updatePlayIcon(); + playbackTick(); + _guideTimerSync(); +} +export function stopPlayback() { + if (S.audioSource) { + try { S.audioSource.stop(); } catch (_) {} + S.audioSource = null; + } + S.playing = false; + updatePlayIcon(); + if (rafId) { cancelAnimationFrame(rafId); rafId = null; } + _guideTimerSync(); + _guideCancelVoices(); + // Restore the reference to its fader level (a stop mid-guide-pass must + // never leave the recording silently muted). + _abApplyRefGain(); +} + +export function playbackTick() { + if (!S.playing) return; + S.cursorTime = _transportChartTimePure(S.playStartTime, S.playStartWall, S.audioCtx.currentTime); + const loopRestart = _recState === 'recording' + ? null + : _loopPlaybackRestartTimePure(S.cursorTime, S.barSel, S.loopEnabled, S.duration); + if (loopRestart !== null) { + // A/B compare flips its pass BEFORE the restart so the ramped + // reference mute/unmute lands with the wrap, not a frame late. + _abOnLoopWrap(); + _restartPlaybackAt(loopRestart); + host.updateTimeDisplay(); + // playbackTick already runs once per animation frame — paint + // synchronously rather than queueing a second rAF via host.draw(). + host.drawNow(); + rafId = requestAnimationFrame(playbackTick); + return; + } + if (S.cursorTime >= S.duration) { + // If a live MIDI recording is active, finalize it at the song end + // before resetting the cursor — otherwise chartTimeNow() keeps + // advancing past S.duration and emits notes beyond the chart. + if (_recState === 'recording') { + window.editorStopRecordMidi(); + } else { + stopPlayback(); + } + S.cursorTime = 0; + host.updateTimeDisplay(); // reflect the reset immediately before returning + host.drawNow(); + return; // stopPlayback() already cancelled rafId; don't re-schedule. + } + + // Auto-scroll to follow the playhead — unless follow is toggled off + // (Shift+L), which lets an author inspect/edit one spot while the + // song plays on. + { + const cx = timeToX(S.cursorTime); + const w = canvas ? canvas.width / DPR : 800; + const target = _followScrollTargetPure( + S.cursorTime, cx, w, S.zoom, editorFollowEnabled()); + if (target !== null) S.scrollX = host.editorClampScrollX(target); + } + + host.updateTimeDisplay(); + host.drawNow(); + rafId = requestAnimationFrame(playbackTick); +} + +/* @pure:follow-scroll:start */ +// Follow-playhead scroll policy: once the cursor crosses 80% of the view, +// jump the window so the cursor sits at 30% — but only when follow is on. +// Returns the UNCLAMPED scrollX target, or null for "don't move". +function _followScrollTargetPure(cursorTime, cursorX, viewW, zoom, followOn) { + if (!followOn) return null; + if (!(cursorX > viewW * 0.8)) return null; + return cursorTime - (viewW * 0.3) / zoom; +} +/* @pure:follow-scroll:end */ + +export function editorFollowEnabled() { + // Default ON — follow is today's behavior; the pref only records an + // explicit opt-out. + try { return localStorage.getItem('editorFollow') !== '0'; } + catch (_) { return true; } +} + +export function _editorToggleFollow() { + const next = !editorFollowEnabled(); + try { localStorage.setItem('editorFollow', next ? '1' : '0'); } catch (_) {} + setStatus(next + ? 'Follow on — the view tracks the playhead during playback (Shift+L)' + : 'Follow off — the view stays put while the song plays (Shift+L)'); + return true; +} + +function updatePlayIcon() { + const icon = document.getElementById('editor-play-icon'); + if (!icon) return; + if (S.playing) { + icon.innerHTML = ''; + } else { + icon.innerHTML = ''; + } +} + +// ════════════════════════════════════════════════════════════════════ +// Guide claps — a percussive tick per charted event during playback, so +// authors can verify note placement by ear (charting-by-ear was silent: +// the editor had zero note sonification). Claps are scheduled by a +// setInterval lookahead loop — NOT the rAF draw loop — so audio timing +// stays sample-accurate even when host.draw() is saturated, and every voice +// sums through a limited master bus (hearing safety). +// ════════════════════════════════════════════════════════════════════ + +/* @pure:guide-clap:start */ +// Half-open window query over a SORTED event-time array: returns the times t +// with from <= t < to, deduplicated at 1 ms resolution so a chord stack +// (several notes at one timestamp) claps once instead of N voices stacking +// into a louder transient. +function _guideClapTimesInWindowPure(times, from, to) { + if (!Array.isArray(times) || !times.length || !(to > from)) return []; + // Binary search for the first index with times[i] >= from. + let lo = 0, hi = times.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (times[mid] < from) lo = mid + 1; else hi = mid; + } + const out = []; + let lastKey = null; + for (let i = lo; i < times.length && times[i] < to; i++) { + const key = Math.round(times[i] * 1000); + if (key === lastKey) continue; + lastKey = key; + out.push(times[i]); + } + return out; +} +// Map chart-seconds onto the AudioContext clock via the transport anchor +// (_startAudioSourceAtCursor records wall/chart time as the audio starts). +function _guideChartToCtxPure(chartT, playStartWall, playStartTime) { + return playStartWall + (chartT - playStartTime); +} +// Sanitize a raw event-time array before the window query, matching every +// other time-array consumer in this file (_editorJumpNote / -Beat / -Anchor): +// drop non-finite entries — a stray NaN/undefined time would reach +// osc.start(NaN) and throw inside the tick, killing clap scheduling — and +// sort ascending, which the early-terminating window scan relies on. +function _guideSanitizeTimesPure(times) { + if (!Array.isArray(times)) return []; + return times.filter(Number.isFinite).sort((a, b) => a - b); +} +// Clamp the lookahead window end to the loop-region end so no clap is +// scheduled past the boundary: the 120 ms lookahead can queue voices for +// events after the loop end before the rAF-detected wrap cancels them +// ("ghost claps" past the loop). No-op when looping is off. +function _guideWindowEndPure(rawTo, loopEnabled, loopEndTime) { + if (loopEnabled && Number.isFinite(loopEndTime)) return Math.min(rawTo, loopEndTime); + return rawTo; +} +// Metronome clicks for the beat rows in [from, to): every beat entry gets a +// click, downbeats (measure > 0) get the accent; sub-beats are measure -1. +// Same half-open window contract as the clap query so the shared scheduler +// never double-fires a beat across adjacent ticks. +function _metroClicksInWindowPure(beats, from, to) { + if (!Array.isArray(beats) || !beats.length || !(to > from)) return []; + let lo = 0, hi = beats.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (beats[mid].time < from) lo = mid + 1; else hi = mid; + } + const out = []; + for (let i = lo; i < beats.length && beats[i].time < to; i++) { + out.push({ t: beats[i].time, accent: beats[i].measure > 0 }); + } + return out; +} +/* @pure:guide-clap:end */ + +const GUIDE_LOOKAHEAD = 0.12; // seconds scheduled ahead of the transport +const GUIDE_TICK_MS = 25; // scheduler cadence +let _guideTimer = null; +let _guideScheduledUntil = 0; // chart-seconds watermark (exclusive) +let _guideVoices = []; // queued {osc, gain, until} for cancel-on-seek +let _guideLastFiredKey = null; // last-fired 1 ms bucket key, PERSISTED across + // ticks so a chord straddling a window boundary + // (same bucket, split by the 25 ms tick) can't + // double-fire — per-window dedupe alone resets. + +export function editorGuideClapEnabled() { + try { return localStorage.getItem('editorGuideClap') === '1'; } + catch (_) { return false; } +} +export function editorMetronomeEnabled() { + try { return localStorage.getItem('editorMetronome') === '1'; } + catch (_) { return false; } +} + +/* @pure:audio-mixer:start */ +// Mixer math for the 3-fader popover (recording / guide / click) and the +// edit-preview blip gating. Fader percents live in editor prefs (never the +// pack) and map linearly onto bus gain, so 100% = the bus's design ceiling +// (unity) — nothing here can boost a bus past the shipped headroom. +const MIX_DEFAULT_PCT = Object.freeze({ ref: 100, guide: 35, click: 25 }); +// Parse a stored fader percent: corrupted values clamp into [0, 100] and +// non-numeric ones fall back, so a bad pref can never blast a bus. +function _mixPctFromStoredPure(raw, fallbackPct) { + const n = parseInt(raw, 10); + if (!Number.isFinite(n)) return fallbackPct; + return Math.max(0, Math.min(100, n)); +} +function _mixGainForPctPure(pct) { + const p = Number(pct); + if (!Number.isFinite(p)) return 0; + return Math.max(0, Math.min(100, p)) / 100; +} +// First play of a session starts the recording below target and ramps up +// (~0.35 s): an unexpectedly hot recording is reached, never jumped to. +// Quiet targets keep a small audible floor so the fade is never mistaken +// for a broken/silent load. +function _mixFirstPlayStartGainPure(target) { + if (!(target > 0)) return 0; + return Math.min(target, Math.max(0.05, target * 0.3)); +} +// Rate-limit for the edit-preview blip: a group edit (set fret on N notes) +// must read as ONE cue, not a machine-gun transient. +function _mixBlipAllowedPure(nowMs, lastMs, gapMs) { + if (!Number.isFinite(lastMs)) return true; + return (nowMs - lastMs) >= gapMs; +} +// A committed drag only previews when it changed PITCH — any string delta +// (a note moved to another string sounds a different pitch) or any fret +// delta (a moved keys/piano-roll pitch, or a fret-changing drag). Time-only +// moves and marquee selects carry no string/fret delta, so they stay silent. +export function _mixDragChangedPitchPure(dstrings, dfrets) { + const ds = Array.isArray(dstrings) && dstrings.some(d => d !== 0); + const df = Array.isArray(dfrets) && dfrets.some(d => d !== 0); + return ds || df; +} +/* @pure:audio-mixer:end */ + +/* @pure:audio-bus:start */ +// Guide-voice bus ONLY: the claps sum through their own gain into a limiter +// so many simultaneous voices can never spike, then to the destination. The +// reference recording deliberately does NOT pass through here — it stays on a +// transparent path straight to destination (see _startAudioSourceAtCursor) so +// the limiter never colors loud / brickwalled reference recordings, whether +// or not guide claps are ever used. +let _masterBus = null; +function _ensureMasterBus() { + if (_masterBus || !S.audioCtx) return _masterBus; + const ctx = S.audioCtx; + const guideGain = ctx.createGain(); + guideGain.gain.value = _mixGainForPctPure(_mixLoadPct().guide); + // Click sits well under the reference/guide by default (≈ -12 dB) — the + // metronome should be felt, not fought with. Both levels come from the + // mixer prefs; the defaults preserve the shipped balance. + const clickGain = ctx.createGain(); + clickGain.gain.value = _mixGainForPctPure(_mixLoadPct().click); + const limiter = ctx.createDynamicsCompressor(); + limiter.threshold.value = -1; + limiter.knee.value = 0; + limiter.ratio.value = 20; + limiter.attack.value = 0.003; + limiter.release.value = 0.25; + guideGain.connect(limiter); + clickGain.connect(limiter); + limiter.connect(ctx.destination); + _masterBus = { guideGain, clickGain, limiter }; + return _masterBus; +} + +// Fader percents, cached so audio paths never read localStorage +// synchronously mid-schedule; seeded once, kept in sync by _mixSetBusGain. +let _mixPctCache = null; +function _mixLoadPct() { + if (_mixPctCache) return _mixPctCache; + let ref = null, guide = null, click = null; + try { + ref = localStorage.getItem('editorMixRef'); + guide = localStorage.getItem('editorMixGuide'); + click = localStorage.getItem('editorMixClick'); + } catch (_) {} + _mixPctCache = { + ref: _mixPctFromStoredPure(ref, MIX_DEFAULT_PCT.ref), + guide: _mixPctFromStoredPure(guide, MIX_DEFAULT_PCT.guide), + click: _mixPctFromStoredPure(click, MIX_DEFAULT_PCT.click), + }; + return _mixPctCache; +} + +// Recording volume node: a TRANSPARENT gain straight to destination — the +// reference still never sums through the guide limiter (see the bus comment +// above). This only adds user volume control; unity by default. +let _refGain = null; +function _ensureRefGain() { + if (_refGain || !S.audioCtx) return _refGain; + _refGain = S.audioCtx.createGain(); + _refGain.gain.value = _mixGainForPctPure(_mixLoadPct().ref); + _refGain.connect(S.audioCtx.destination); + return _refGain; +} + +// First-play fade (hearing safety): once per loaded recording, the +// reference ramps from a reduced level up to its fader target as playback +// starts. Re-armed by _mixResetFirstPlay() on every new/replaced recording +// (see loadAudio()) — the ramp guards against an unexpectedly hot recording, +// so it must not go stale after the very first song of a session. +let _mixFirstPlayDone = false; +function _mixApplyFirstPlayFade() { + if (_mixFirstPlayDone || !_refGain || !S.audioCtx) return; + _mixFirstPlayDone = true; + const target = _mixGainForPctPure(_mixLoadPct().ref); + const now = S.audioCtx.currentTime; + _refGain.gain.setValueAtTime(_mixFirstPlayStartGainPure(target), now); + _refGain.gain.linearRampToValueAtTime(target, now + 0.35); +} + +// Re-arm the first-play fade: called whenever a new reference recording is +// decoded (loadCDLC, create/import, and replace-audio all funnel through +// loadAudio()) so each new recording gets the hearing-safety ramp, not just +// the first one of the screen's lifetime. +function _mixResetFirstPlay() { + _mixFirstPlayDone = false; +} + +// Apply a fader move: persist the pref and ramp the live node (~20 ms +// smoothing) — a gain change is never a stepped jump mid-audio. +function _mixSetBusGain(bus, pct) { + const key = bus === 'ref' ? 'editorMixRef' + : bus === 'guide' ? 'editorMixGuide' : 'editorMixClick'; + const p = _mixPctFromStoredPure(String(pct), MIX_DEFAULT_PCT[bus]); + _mixLoadPct()[bus] = p; + try { localStorage.setItem(key, String(p)); } catch (_) {} + const node = bus === 'ref' ? _refGain + : bus === 'guide' ? (_masterBus && _masterBus.guideGain) + : (_masterBus && _masterBus.clickGain); + if (node && S.audioCtx) { + // The recording fader must never un-mute an active A/B guide pass: + // route ref moves through the A/B-aware target so a nudge ramps to + // the fresh level on a recording pass but stays muted on a guide + // pass. Guarded — the @pure:audio-bus test sandbox has no + // _abApplyRefGain, where this falls back to the plain fader ramp. + if (bus === 'ref' && typeof _abApplyRefGain === 'function') { + _abApplyRefGain(); + } else { + node.gain.setTargetAtTime(_mixGainForPctPure(p), S.audioCtx.currentTime, 0.02); + } + } + return p; +} + +export function editorEditBlipEnabled() { + try { return localStorage.getItem('editorEditBlip') !== '0'; } + catch (_) { return true; } +} + +// Edit-preview blip: a soft confirmation tick on note ADD and PITCH change +// only (never marquee/time-only moves). It sums straight into the shared +// limiter — NOT through the guide fader — so muting guide claps never also +// silences the edit cue, while the limiter still tames it. It skips when the +// context isn't running — an edit must never resume audio — and is pitched +// apart from the 1750 Hz guide clap so the two read as different cues. +let _mixLastBlipMs = null; +export function _editBlipAt() { + if (!editorEditBlipEnabled()) return; + if (!S.audioCtx || S.audioCtx.state !== 'running') return; + const bus = _ensureMasterBus(); + if (!bus) return; + const nowMs = Date.now(); + if (!_mixBlipAllowedPure(nowMs, _mixLastBlipMs, 60)) return; + _mixLastBlipMs = nowMs; + const ctx = S.audioCtx; + const when = ctx.currentTime; + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.value = 1320; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.0001, when); + g.gain.exponentialRampToValueAtTime(0.5, when + 0.002); + g.gain.exponentialRampToValueAtTime(0.0001, when + 0.04); + osc.connect(g); + g.connect(bus.limiter); + osc.start(when); + osc.stop(when + 0.05); + _guideVoices.push({ osc, gain: g, until: when + 0.05 }); + // Same bounded-bookkeeping rule as the scheduler tick. + if (_guideVoices.length > 64) { + const nowCtx = ctx.currentTime; + _guideVoices = _guideVoices.filter(v => v.until > nowCtx); + } +} + +// Audition one pitch for the keyboard gutter (click a piano key → hear it). +// A gentle, hearing-safe voice through the master limiter (soft attack, ~0.28 +// peak, ~320 ms decay) — the same envelope shape as the edit blip but pitched +// and a touch longer, so it reads as a note rather than a tick. No-op when the +// context isn't running (autoplay-gated) or the pitch is out of audible range. +export function _auditionPitch(midi) { + if (!S.audioCtx || S.audioCtx.state !== 'running') return; + const freq = midiToFreq(midi); + if (!(freq > 0) || freq > 20000) return; + const bus = _ensureMasterBus(); + if (!bus) return; + const ctx = S.audioCtx; + const when = ctx.currentTime; + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.value = freq; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.0001, when); + g.gain.exponentialRampToValueAtTime(0.28, when + 0.006); + g.gain.exponentialRampToValueAtTime(0.0001, when + 0.32); + osc.connect(g); + g.connect(bus.limiter); + osc.start(when); + osc.stop(when + 0.34); + _guideVoices.push({ osc, gain: g, until: when + 0.34 }); + if (_guideVoices.length > 64) { + const nowCtx = ctx.currentTime; + _guideVoices = _guideVoices.filter(v => v.until > nowCtx); + } +} +/* @pure:audio-bus:end */ + +// Event times for the active editing surface: the drum grid claps drum hits, +// every other view claps the current arrangement's (time-sorted) notes. +function _guideSourceTimes() { + if (S.drumEditMode) { + const hits = (S.drumTab && Array.isArray(S.drumTab.hits)) ? S.drumTab.hits : []; + return _guideSanitizeTimesPure(hits.map(h => h.t)); + } + if (!S.arrangements.length) return []; + return _guideSanitizeTimesPure(notes().map(n => n.time)); +} + +function _guideClapVoiceAt(when) { + const bus = _ensureMasterBus(); + if (!bus) return; + const ctx = S.audioCtx; + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.value = 1750; + const g = ctx.createGain(); + // Soft tick: 3 ms ramp in (never a 0 ms transient) and ~45 ms exponential + // decay — a locatable placement cue without startle. + g.gain.setValueAtTime(0.0001, when); + g.gain.exponentialRampToValueAtTime(0.8, when + 0.003); + g.gain.exponentialRampToValueAtTime(0.0001, when + 0.048); + osc.connect(g); + g.connect(bus.guideGain); + osc.start(when); + osc.stop(when + 0.06); + _guideVoices.push({ osc, gain: g, until: when + 0.06 }); +} + +// Metronome click: a band-limited soft pip. The accent (downbeat) is +// differentiated mainly by PITCH (~1000 vs ~800 Hz) with only a small level +// delta — the hearing-safe way to accent, rather than a louder transient. +function _metroClickVoiceAt(when, accent) { + const bus = _ensureMasterBus(); + if (!bus) return; + const ctx = S.audioCtx; + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.value = accent ? 1000 : 800; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.0001, when); + g.gain.exponentialRampToValueAtTime(accent ? 0.9 : 0.68, when + 0.002); + g.gain.exponentialRampToValueAtTime(0.0001, when + 0.04); + osc.connect(g); + g.connect(bus.clickGain); + osc.start(when); + osc.stop(when + 0.05); + _guideVoices.push({ osc, gain: g, until: when + 0.05 }); +} + +// Cancel every queued-but-unfinished clap — stale voices would otherwise +// fire at their pre-seek positions after a loop wrap or scrub. +function _guideCancelVoices() { + for (const v of _guideVoices) { + try { v.osc.stop(); } catch (_) {} + try { v.gain.disconnect(); } catch (_) {} + } + _guideVoices = []; +} + +function _guideResetSchedule() { + _guideCancelVoices(); + _guideScheduledUntil = S.cursorTime || 0; + _guideLastFiredKey = null; // a seek/wrap breaks cross-tick dedupe continuity +} + +function _guideTick() { + // A/B overrides the claps pref while active: guide passes clap even + // with the pref off; recording passes stay clean even with it on. + const claps = _abClapsEnabledPure(_abActive(), _abPhase, editorGuideClapEnabled()); + const metro = editorMetronomeEnabled(); + if (!S.playing || !S.audioCtx || (!claps && !metro)) return; + const nowChart = _transportChartTimePure(S.playStartTime, S.playStartWall, S.audioCtx.currentTime); + // Clamp the lookahead end to the loop-region end while looping, so no clap + // is scheduled past the boundary before the rAF wrap cancels the window. + const loopRegion = S.loopEnabled ? _normalizeLoopRegionPure(S.barSel, S.duration) : null; + const to = _guideWindowEndPure( + nowChart + GUIDE_LOOKAHEAD, !!loopRegion, loopRegion ? loopRegion.endTime : NaN); + // If the timer stalled (hidden tab), skip events that are already in the + // past rather than machine-gunning them late; 5 ms of grace keeps an + // event exactly at the cursor audible. + const from = Math.max(_guideScheduledUntil, nowChart - 0.005); + if (to <= from) return; + if (claps) { + const times = _guideClapTimesInWindowPure(_guideSourceTimes(), from, to); + for (const t of times) { + // Cross-tick dedupe: skip an event in the same 1 ms bucket as the last + // clap already fired in a previous window (chord split by the boundary). + const key = Math.round(t * 1000); + if (key === _guideLastFiredKey) continue; + _guideLastFiredKey = key; + _guideClapVoiceAt(_guideChartToCtxPure(t, S.playStartWall, S.playStartTime)); + } + } + if (metro) { + const clicks = _metroClicksInWindowPure(S.beats || [], from, to); + for (const c of clicks) { + _metroClickVoiceAt( + _guideChartToCtxPure(c.t, S.playStartWall, S.playStartTime), c.accent); + } + } + _guideScheduledUntil = to; + // Drop bookkeeping for voices that already finished (bounded memory). + if (_guideVoices.length > 64) { + const nowCtx = S.audioCtx.currentTime; + _guideVoices = _guideVoices.filter(v => v.until > nowCtx); + } +} + +// ── Loop A/B compare — the ear-training loop ───────────────────────── +// While looping, alternate each pass between the RECORDING (reference +// audible, claps off) and the GUIDE (reference muted via the mixer's +// transparent ref gain, claps on) so a charter can hear what they charted +// against what the artist played, one pass apart. Session-only state — +// deliberately not persisted: silently muting the recording on a later +// session would read as a playback bug. + +/* @pure:loop-ab:start */ +// Do claps schedule this tick? A/B overrides the claps pref while active: +// guide passes clap even with the pref off, recording passes stay clean +// even with it on. +function _abClapsEnabledPure(abActive, phase, clapsPref) { + return abActive ? phase === 'guide' : clapsPref; +} +function _abNextPhasePure(phase) { + return phase === 'guide' ? 'recording' : 'guide'; +} +// The reference gain target: muted only during an ACTIVE A/B guide pass +// while playing; every other state restores the mixer fader's value. +function _abRefTargetPure(abActive, playing, phase, faderGain) { + return (abActive && playing && phase === 'guide') ? 0 : faderGain; +} +/* @pure:loop-ab:end */ + +export let _abOn = false; +export let _abPhase = 'recording'; // every play starts by hearing the real thing + +// A/B compares the recording against the guide — meaningless with no reference +// buffer (compose mode), where it would only gate half of each loop's claps to +// silence. Require a buffer so compose loops keep every clap. +function _abActive() { return _abOn && !!S.loopEnabled && !!S.audioBuffer; } + +// Disarm A/B and restore the reference gain. main.js calls this from the loop +// disarm and the song-change reset — the only A/B state writes outside this +// module, kept here because the state is a live export (read-only to importers). +export function _abDisarm() { + _abOn = false; + _abPhase = 'recording'; + _abApplyRefGain(); +} + +export function _abApplyRefGain() { + const rg = _ensureRefGain(); + if (!rg || !S.audioCtx) return; + const target = _abRefTargetPure( + _abActive(), !!S.playing, _abPhase, + _mixGainForPctPure(_mixLoadPct().ref)); + // Same ~20 ms ramp as every mixer move — a phase flip is never a pop. + rg.gain.setTargetAtTime(target, S.audioCtx.currentTime, 0.02); +} + +function _abOnLoopWrap() { + if (!_abActive()) return; + _abPhase = _abNextPhasePure(_abPhase); + _abApplyRefGain(); + setStatus(_abPhase === 'guide' + ? 'A/B: guide pass (recording muted)' + : 'A/B: recording pass'); +} + +export function _refreshLoopABBtn() { + const btn = document.getElementById('editor-loop-ab-btn'); + if (!btn) return; + const region = host.selectedLoopRegion(); + btn.disabled = !region; + btn.classList.toggle('bg-accent', _abOn); + btn.classList.toggle('hover:bg-accent-light', _abOn); + btn.classList.toggle('bg-dark-600', !_abOn); + btn.classList.toggle('hover:bg-dark-500', !_abOn); + btn.setAttribute('aria-pressed', _abOn ? 'true' : 'false'); + btn.title = region + ? 'A/B compare: each loop pass alternates — recording, then guide claps only (Alt+B)' + : 'Set a loop region first — A/B alternates recording and guide per pass'; +} + +export function _editorToggleLoopAB() { + if (!_abOn && !host.selectedLoopRegion()) { + setStatus('Set a loop region first — A/B alternates recording and guide per pass'); + return true; + } + _abOn = !_abOn; + _abPhase = 'recording'; + if (_abOn && !S.loopEnabled && host.selectedLoopRegion()) { + // A/B is meaningless without looping — arm the loop exactly like the + // Loop button, including the seek into the region when the cursor + // sits outside it, so A/B never rides a pre-loop stretch of audio. + host.setLoopRegionEnabled(true); + } + _abApplyRefGain(); + _refreshLoopABBtn(); + _guideTimerSync(); // guide passes need the scheduler even with claps off + setStatus(_abOn + ? 'Loop A/B on — first pass plays the recording, the next plays only the guide claps' + : 'Loop A/B off'); + return true; +} +// window.editorToggleLoopAB re-attached in main.js + +// Start/stop the scheduler to match "playing AND enabled". Called from +// startPlayback/stopPlayback and from the toggle (mid-play enable works). +export function _guideTimerSync() { + const want = S.playing + && (editorGuideClapEnabled() || editorMetronomeEnabled() || _abActive()); + if (want && !_guideTimer) { + _guideScheduledUntil = _transportChartTimePure( + S.playStartTime, S.playStartWall, S.audioCtx.currentTime); + _guideTimer = setInterval(_guideTick, GUIDE_TICK_MS); + _guideTick(); // fill the first window now, not one tick late + } else if (!want && _guideTimer) { + clearInterval(_guideTimer); + _guideTimer = null; + } +} + +export function _refreshGuideBtn() { + const btn = document.getElementById('editor-guide-btn'); + if (!btn) return; + const on = editorGuideClapEnabled(); + btn.classList.toggle('bg-accent', on); + btn.classList.toggle('hover:bg-accent-light', on); + btn.classList.toggle('bg-dark-600', !on); + btn.classList.toggle('hover:bg-dark-500', !on); + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); +} + +export function _editorToggleGuideClap() { + const next = !editorGuideClapEnabled(); + try { localStorage.setItem('editorGuideClap', next ? '1' : '0'); } catch (_) {} + _refreshGuideBtn(); + _guideTimerSync(); + setStatus(next + ? 'Guide claps on — charted notes tick during playback (C toggles)' + : 'Guide claps off'); + return true; +} +// window.editorToggleGuideClap re-attached in main.js + +export function _refreshMetronomeBtn() { + const btn = document.getElementById('editor-metronome-btn'); + if (!btn) return; + const on = editorMetronomeEnabled(); + btn.classList.toggle('bg-accent', on); + btn.classList.toggle('hover:bg-accent-light', on); + btn.classList.toggle('bg-dark-600', !on); + btn.classList.toggle('hover:bg-dark-500', !on); + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); +} + +export function _editorToggleMetronome() { + const next = !editorMetronomeEnabled(); + try { localStorage.setItem('editorMetronome', next ? '1' : '0'); } catch (_) {} + _refreshMetronomeBtn(); + _guideTimerSync(); + setStatus(next + ? 'Metronome on — clicks follow the beat grid, accented on downbeats' + : 'Metronome off'); + return true; +} +// window.editorToggleMetronome re-attached in main.js + +// ── Audio mixer popover ────────────────────────────────────────────── +export function _refreshMixerBtn() { + const btn = document.getElementById('editor-mixer-btn'); + if (!btn) return; + const panel = document.getElementById('editor-audio-mixer'); + const open = !!(panel && !panel.classList.contains('hidden')); + btn.classList.toggle('bg-accent', open); + btn.classList.toggle('hover:bg-accent-light', open); + btn.classList.toggle('bg-dark-600', !open); + btn.classList.toggle('hover:bg-dark-500', !open); + btn.setAttribute('aria-pressed', open ? 'true' : 'false'); +} + +function _refreshMixerUI() { + const pcts = _mixLoadPct(); + for (const [bus, id] of [['ref', 'editor-mix-ref'], ['guide', 'editor-mix-guide'], ['click', 'editor-mix-click']]) { + const slider = document.getElementById(id); + const label = document.getElementById(id + '-val'); + if (slider) slider.value = String(pcts[bus]); + if (label) label.textContent = pcts[bus] + '%'; + } + const blip = document.getElementById('editor-mix-blip'); + if (blip) blip.checked = editorEditBlipEnabled(); +} + +export function _editorToggleMixer(force) { + const panel = document.getElementById('editor-audio-mixer'); + if (!panel) return false; + const show = force === undefined ? panel.classList.contains('hidden') : !!force; + panel.classList.toggle('hidden', !show); + if (show) _refreshMixerUI(); + _refreshMixerBtn(); + return true; +} +// window.editorToggleMixer re-attached in main.js + +export function editorSetMixLevel(bus, val) { + if (bus !== 'ref' && bus !== 'guide' && bus !== 'click') return; + const p = _mixSetBusGain(bus, val); + const label = document.getElementById( + (bus === 'ref' ? 'editor-mix-ref' : bus === 'guide' ? 'editor-mix-guide' : 'editor-mix-click') + '-val'); + if (label) label.textContent = p + '%'; +} + +export function editorSetEditBlip(on) { + try { localStorage.setItem('editorEditBlip', on ? '1' : '0'); } catch (_) {} + setStatus(on + ? 'Edit blip on — a soft tick confirms note adds and pitch changes' + : 'Edit blip off'); +} + +// Wired by main.js's init(), not at import — a module must have no side +// effects when it is loaded, or its tests cannot import it without a DOM. +// Seeds every audio toolbar button and restores the snap-mode pref. +export function initAudio() { + _refreshOnsetBtn(); + // Seed the snap target from the persisted editor pref (grid by default). + try { + if (localStorage.getItem('editorSnapMode') === 'onset') S.snapMode = 'onset'; + } catch (_) {} + _refreshSnapModeBtn(); + _refreshGuideBtn(); + _refreshMetronomeBtn(); + _refreshMixerBtn(); +} + + +// Stop playback and cancel every loop this module owns — main.js's screen +// teardown calls this so a replaced editor screen doesn't keep sounding or +// scheduling. The old inline teardown cancelled only the audio source and the +// rAF frame; the guide/metronome setInterval outlived it (a latent leak, since +// _guideTimer is module-scope and the module is never re-loaded). Clearing +// S.playing and syncing drops it — _guideTimerSync stops the timer when nothing +// wants it — and _guideCancelVoices silences any queued oscillators (Codex). +export function teardownAudio() { + try { if (S.audioSource) { S.audioSource.stop(); S.audioSource = null; } } catch (_) { /* already stopped */ } + try { if (rafId) { cancelAnimationFrame(rafId); rafId = null; } } catch (_) { /* no frame queued */ } + S.playing = false; + _guideTimerSync(); + _guideCancelVoices(); +} diff --git a/src/host.js b/src/host.js index d9246be2..ed42c1f2 100644 --- a/src/host.js +++ b/src/host.js @@ -114,6 +114,18 @@ export const host = { */ finalizeActiveDrag: () => {}, + // ── Rendering and scroll, for src/audio.js ──────────────────────── + /** Force an immediate synchronous repaint (draw() is rAF-coalesced). */ + drawNow: () => {}, + /** Clamp a scrollX to the song bounds. */ + editorClampScrollX: (x) => x, + /** Re-apply scroll bounds after the viewport or duration changed. */ + editorApplyScrollBounds: () => {}, + /** The A/B loop region currently selected, or null. */ + selectedLoopRegion: () => null, + /** Enable/disable looping over the selected region. */ + setLoopRegionEnabled: () => {}, + // ── Dialogs and canvas geometry, for src/inspector.js ──────────── /** Open the bend-curve editor for a note. Async: resolves when it closes. */ promptBend: async () => {}, diff --git a/src/main.js b/src/main.js index bb719fd2..20c2a778 100644 --- a/src/main.js +++ b/src/main.js @@ -16,8 +16,7 @@ import { beatOf, timeOf } from './beats.js'; import { } from './position.js'; import { - _composeSongDurationPure, _loopPlaybackRestartTimePure, _normalizeLoopRegionPure, - _transportChartTimePure, + _normalizeLoopRegionPure, } from './transport.js'; import { _editorEscHtml, _editorPromptText, _installModalKeyboard, setStatus } from './ui.js'; import { hitNote, hitNoteEdge } from './hit-test.js'; @@ -55,6 +54,14 @@ import { import { hideContextMenu, promptBend, promptFret, promptSlide, promptSlideUnpitch, showContextMenu, } from './context-menu.js'; +import { + _abApplyRefGain, _abDisarm, _abOn, _auditionPitch, _editBlipAt, _editorToggleFollow, + _editorToggleGuideClap, _editorToggleLoopAB, _editorToggleMetronome, _editorToggleMixer, + _editorToggleOnsetStrip, _editorToggleSnapMode, _ensureOnsets, _guideTimerSync, + _mixDragChangedPitchPure, _nearestOnsetTimePure, _onsetStripEnabled, _refreshLoopABBtn, + editorSetEditBlip, editorSetMixLevel, initAudio, loadAudio, startPlayback, stopPlayback, + teardownAudio, +} from './audio.js'; import { setHostHooks } from './host.js'; import { MIN_MEASURE, TempoGridCmd, TempoMapCmd, _editorModulateTempoAtSelection, @@ -145,8 +152,8 @@ import { import { KEYS_PATTERN, PIANO_LANE_H, _inKeyboardGutterPure, _partViewKeyPure, _rollLockNotice, _rollMidiForNote, _rollPitchCtx, _rollReadOnly, _uniqueKeysName, _viewPrefs, - _viewPrefsSave, isKeysArr, isKeysMode, midiToFreq, midiToFret, midiToNote, midiToString, - midiToY, noteToMidi, pianoLaneCount, updatePianoRange, viewFor, yToMidi, + _viewPrefsSave, isKeysArr, isKeysMode, midiToFret, midiToNote, midiToString, midiToY, + noteToMidi, pianoLaneCount, updatePianoRange, viewFor, yToMidi, } from './keys.js'; import { _resizeSustainsForDeltaPure, _resizeTargetIndicesPure, _restoreSuggestedMarks, @@ -170,7 +177,6 @@ import { -let rafId = null; // ════════════════════════════════════════════════════════════════════ // Coordinate mapping @@ -557,12 +563,8 @@ function _updateLoopRegionControls() { if (!region && S.loopEnabled) S.loopEnabled = false; // A/B rides the loop region — clearing the region disarms it (and // restores the reference gain via the guarded apply). - if (!region && typeof _abOn !== 'undefined' && _abOn) { - _abOn = false; - _abPhase = 'recording'; - if (typeof _abApplyRefGain === 'function') _abApplyRefGain(); - } - if (typeof _refreshLoopABBtn === 'function') _refreshLoopABBtn(); + if (!region && _abOn) _abDisarm(); + _refreshLoopABBtn(); const loopBtn = document.getElementById('editor-loop-region-btn'); if (loopBtn) { loopBtn.disabled = !region; @@ -1301,6 +1303,11 @@ setHostHooks({ resetOffsetUI: _resetOffsetUI, updateTimeDisplay, addGlobalListener: (target, ev, fn) => _globalListeners.add(target, ev, fn), + drawNow: (...args) => drawNow(...args), + editorClampScrollX: _editorClampScrollX, + editorApplyScrollBounds: _editorApplyScrollBounds, + selectedLoopRegion: _selectedLoopRegion, + setLoopRegionEnabled: _setLoopRegionEnabled, }); window.editorHideRecordMidiModal = editorHideRecordMidiModal; @@ -1324,6 +1331,14 @@ window.editorInspectorSetScaleDegree = editorInspectorSetScaleDegree; window.editorInspectorSetTech = editorInspectorSetTech; window.editorOpenBendCurve = editorOpenBendCurve; window.editorUngroupStrum = editorUngroupStrum; +window.editorSetEditBlip = editorSetEditBlip; +window.editorSetMixLevel = editorSetMixLevel; +window.editorToggleGuideClap = _editorToggleGuideClap; +window.editorToggleLoopAB = _editorToggleLoopAB; +window.editorToggleMetronome = _editorToggleMetronome; +window.editorToggleMixer = _editorToggleMixer; +window.editorToggleOnsetStrip = _editorToggleOnsetStrip; +window.editorToggleSnapMode = _editorToggleSnapMode; window.editorShowRecordMidiModal = editorShowRecordMidiModal; window.editorStartRecordMidi = editorStartRecordMidi; window.editorStopRecordMidi = editorStopRecordMidi; @@ -3477,8 +3492,7 @@ window.__editorScreenTeardown = () => { _globalListeners.removeAll(); // Stop any playback this injection owns — the audio graph outlives the // DOM, so a replaced screen would otherwise keep sounding. - try { if (S.audioSource) { S.audioSource.stop(); S.audioSource = null; } } catch (_) {} - try { if (rafId) { cancelAnimationFrame(rafId); rafId = null; } } catch (_) {} + teardownAudio(); // stops playback + cancels the rAF loop (src/audio.js owns both) try { if (_editorScreenObs) { _editorScreenObs.disconnect(); _editorScreenObs = null; } } catch (_) {} try { if (_v3TopbarWatch) { _v3TopbarWatch.disconnect(); _v3TopbarWatch = null; } } catch (_) {} // The v3 layout ResizeObserver watches #v3-topbar, a shell-persistent node @@ -3505,1065 +3519,6 @@ _globalListeners.add(document, 'keydown', (e) => { } }); -// ════════════════════════════════════════════════════════════════════ -// Audio / Playback -// ════════════════════════════════════════════════════════════════════ - -// Lazily create the shared AudioContext. Compose mode never decodes a -// recording (loadAudio is the only other creation site), yet the transport -// clock + metronome/guide voices still need a context to schedule on — make -// one on demand. Call from a user gesture (decode / play) so the browser does -// not hand back a permanently-suspended context. -function _ensureAudioCtx() { - if (!S.audioCtx) { - const Ctor = window.AudioContext || window.webkitAudioContext; - if (!Ctor) return null; // no Web Audio — leave S.audioCtx unset so callers bail - S.audioCtx = new Ctor(); - } - return S.audioCtx; -} - -async function loadAudio(url) { - if (!url) return; - try { - _ensureAudioCtx(); - const resp = await fetch(url); - const buf = await resp.arrayBuffer(); - S.audioBuffer = await S.audioCtx.decodeAudioData(buf); - S.duration = S.audioBuffer.duration; - // A new recording is loaded — re-arm the hearing-safety fade so it - // applies to this recording too, not just the session's first one. - _mixResetFirstPlay(); - _editorApplyScrollBounds(); - computeWaveform(); - } catch (e) { - console.error('Audio load error:', e); - } -} - -// Build a high-resolution min / max / RMS cache from one channel of PCM so -// the waveform can render its true (asymmetric) shape and stay sharp when -// zoomed in: `min`/`max` are the signed sample extremes per bin (the peak -// envelope), `rms` is the per-bin loudness (the body). Pure — channel data -// in, typed arrays out — so it's unit-testable. `bins` is the entry count. -function _buildWaveformPeaks(data, binSamples) { - const bins = Math.max(1, Math.floor(data.length / binSamples)); - const min = new Float32Array(bins); - const max = new Float32Array(bins); - const rms = new Float32Array(bins); - for (let b = 0; b < bins; b++) { - const start = b * binSamples; - // The last bin soaks up any remainder so no tail samples are dropped. - const end = (b === bins - 1) ? data.length : start + binSamples; - let lo = Infinity, hi = -Infinity, sumSq = 0, cnt = 0; - for (let s = start; s < end; s++) { - const v = data[s]; - if (v < lo) lo = v; - if (v > hi) hi = v; - sumSq += v * v; - cnt++; - } - min[b] = cnt ? lo : 0; - max[b] = cnt ? hi : 0; - rms[b] = cnt ? Math.sqrt(sumSq / cnt) : 0; - } - return { min, max, rms, bins }; -} - -function computeWaveform() { - if (!S.audioBuffer) return; - const data = S.audioBuffer.getChannelData(0); - // ~3 ms per bin: fine enough that each pixel covers ≥1 bin even at high - // zoom, yet bounded (≈1 MB of typed arrays for a 5-minute song). - const binSamples = Math.max(64, Math.round(S.audioBuffer.sampleRate * 0.003)); - S.waveformPeaks = _buildWaveformPeaks(data, binSamples); - // New audio ⇒ any cached onset analysis is stale. - _onsetCache = null; -} - -/* @pure:onset-strip:start */ -// Transient/onset estimation from the waveform RMS cache — a cheap -// client-side "where do events probably live" hint (no server round-trip, -// no DSP deps). An onset fires where the RMS rises sharply above the local -// baseline (the mean of the preceding window), gated by an absolute noise -// floor and a refractory gap so one attack registers once. Returns -// [{t, s}] — time in seconds and a 0..1 strength. -function _onsetTimesFromPeaksPure(rms, binSec, opts) { - if (!rms || !rms.length || !(binSec > 0)) return []; - const o = opts || {}; - const baselineBins = Math.max(2, o.baselineBins || 16); - const ratio = o.ratio || 1.5; - const floorFrac = o.floorFrac || 0.05; - const riseFrac = o.riseFrac || 0.03; - const minGapSec = o.minGapSec || 0.05; - let global = 0; - for (let i = 0; i < rms.length; i++) if (rms[i] > global) global = rms[i]; - if (!(global > 0)) return []; - const floor = global * floorFrac; - const refractory = Math.max(1, Math.round(minGapSec / binSec)); - const out = []; - let sum = 0; - for (let i = 0; i < Math.min(baselineBins, rms.length); i++) sum += rms[i]; - let lastOnset = -Infinity; - for (let i = baselineBins; i < rms.length; i++) { - const base = sum / baselineBins; - const v = rms[i]; - if (v > floor && v > rms[i - 1] - && v > base * ratio && v - base > global * riseFrac - && i - lastOnset >= refractory) { - out.push({ - t: i * binSec, - s: Math.max(0, Math.min(1, (v - base) / global)), - }); - lastOnset = i; - } - // Slide the baseline window. - sum += v - rms[i - baselineBins]; - } - return out; -} -/* @pure:onset-strip:end */ - -/* @pure:onset-snap:start */ -// Nearest-onset snap: given time-sorted onsets [{t,...}], return the onset -// time nearest to `t` when it lies within `tol` seconds, else null (the caller -// falls back to grid snap). Binary-searches the sorted onsets so the hot drag -// path stays O(log n). Guards non-finite t, empty onsets, and tol <= 0. -function _nearestOnsetTimePure(onsets, t, tol) { - if (!Array.isArray(onsets) || onsets.length === 0) return null; - if (!Number.isFinite(t) || !(tol > 0)) return null; - // First onset with .t >= t. - let lo = 0, hi = onsets.length; - while (lo < hi) { - const mid = (lo + hi) >> 1; - if (onsets[mid].t < t) lo = mid + 1; else hi = mid; - } - // The nearest onset is one of onsets[lo-1] (last before t) / onsets[lo]. - let best = null, bestD = Infinity; - for (let i = lo - 1; i <= lo; i++) { - if (i < 0 || i >= onsets.length) continue; - const o = onsets[i]; - if (!o || !Number.isFinite(o.t)) continue; - const d = Math.abs(o.t - t); - if (d < bestD) { bestD = d; best = o.t; } - } - return bestD <= tol ? best : null; -} -/* @pure:onset-snap:end */ - -// ── Onset strip toggle + lazy cache ────────────────────────────────── -let _onsetCache = null; // [{t, s}] for the CURRENT waveformPeaks -let _onsetStripOn = null; // cached enabled flag; null until first read - -function _onsetStripEnabled() { - // Cache the flag so the draw path (every frame during playback) doesn't - // hit localStorage synchronously. Seeded once from storage, then kept in - // sync by _editorToggleOnsetStrip. - if (_onsetStripOn === null) { - try { _onsetStripOn = localStorage.getItem('editorOnsetStrip') === '1'; } - catch (_) { _onsetStripOn = false; } - } - return _onsetStripOn; -} - -function _ensureOnsets() { - if (_onsetCache) return _onsetCache; - const pk = S.waveformPeaks; - const dur = S.duration || 0; - if (!pk || !pk.bins || !pk.rms || dur <= 0) return null; - _onsetCache = _onsetTimesFromPeaksPure(pk.rms, dur / pk.bins); - return _onsetCache; -} - -function _refreshOnsetBtn() { - const btn = document.getElementById('editor-onset-btn'); - if (!btn) return; - const on = _onsetStripEnabled(); - btn.classList.toggle('bg-accent', on); - btn.classList.toggle('hover:bg-accent-light', on); - btn.classList.toggle('bg-dark-600', !on); - btn.classList.toggle('hover:bg-dark-500', !on); - btn.setAttribute('aria-pressed', on ? 'true' : 'false'); -} - -function _editorToggleOnsetStrip() { - const next = !_onsetStripEnabled(); - _onsetStripOn = next; - try { localStorage.setItem('editorOnsetStrip', next ? '1' : '0'); } catch (_) {} - _refreshOnsetBtn(); - draw(); - setStatus(next - ? 'Onset strip on — amber blocks mark detected attacks in the recording (display only)' - : 'Onset strip off'); - return true; -} -window.editorToggleOnsetStrip = _editorToggleOnsetStrip; -_refreshOnsetBtn(); - -// ── Snap target: grid ↔ audio onset ────────────────────────────────── -function _refreshSnapModeBtn() { - const btn = document.getElementById('editor-snapmode-btn'); - if (!btn) return; - const onset = S.snapMode === 'onset'; - btn.textContent = onset ? 'Onset' : 'Grid'; - btn.classList.toggle('bg-accent', onset); - btn.classList.toggle('hover:bg-accent-light', onset); - btn.classList.toggle('bg-dark-600', !onset); - btn.classList.toggle('hover:bg-dark-500', !onset); - btn.setAttribute('aria-pressed', onset ? 'true' : 'false'); -} - -function _editorToggleSnapMode() { - S.snapMode = S.snapMode === 'onset' ? 'grid' : 'onset'; - try { localStorage.setItem('editorSnapMode', S.snapMode); } catch (_) {} - _refreshSnapModeBtn(); - if (S.snapMode === 'onset') { - const onsets = _ensureOnsets(); - setStatus(onsets && onsets.length - ? 'Snap to onset — placement snaps to the nearest detected attack (falls back to grid when none is near)' - : 'Snap to onset — no transients detected yet (load a recording, turn on Onsets); snapping to grid until then'); - } else { - setStatus('Snap to grid — placement snaps to the tempo-map subdivisions'); - } - return true; -} -window.editorToggleSnapMode = _editorToggleSnapMode; - -// Seed the snap target from the persisted editor pref (grid by default). -try { - if (localStorage.getItem('editorSnapMode') === 'onset') S.snapMode = 'onset'; -} catch (_) {} -_refreshSnapModeBtn(); - -function _startAudioSourceAtCursor() { - S.audioSource = S.audioCtx.createBufferSource(); - S.audioSource.buffer = S.audioBuffer; - // Reference recording stays on a transparent path to destination — its - // mixer fader is a plain gain (unity by default): the guide-clap limiter - // must never color the recording, even when claps are off. Only the - // guide/click voices sum through the limiter (see _ensureMasterBus). - const refGain = _ensureRefGain(); - if (refGain) S.audioSource.connect(refGain); - else S.audioSource.connect(S.audioCtx.destination); - _mixApplyFirstPlayFade(); - S.audioSource.start(0, S.cursorTime); - _anchorTransportAtCursor(); -} - -// Anchor the transport clock at the current cursor: pin wall-time to the -// AudioContext clock and chart-time to cursorTime, so playbackTick can derive -// the cursor from the ctx clock. In buffered mode this rides alongside the -// BufferSource; in compose mode it IS the whole clock (there is no source). -// Every (re)start is a seek from the clap scheduler's perspective, so drop -// already-queued voices and restart the window at the new cursor — otherwise -// claps scheduled before a loop wrap / seek fire at their old positions -// ("ghost claps"). -function _anchorTransportAtCursor() { - S.playStartWall = S.audioCtx.currentTime; - S.playStartTime = S.cursorTime; - _guideResetSchedule(); -} - -// Resolve compose-mode duration from live state: the grid end via the A1 -// converter (timeOf of the last beat), the last authored event on the active -// surface, and an optional user-set length (S.composeLength). Buffered mode -// never calls this — there S.duration is the recording's own length. -function _composeSongDuration() { - const userLen = (typeof S.composeLength === 'number') ? S.composeLength : NaN; - const gridEnd = (S.beats && S.beats.length >= 2) - ? timeOf(S.beats, S.beats.length - 1) - : 0; - let contentEnd = 0; - for (const t of _guideSourceTimes()) if (t > contentEnd) contentEnd = t; - return _composeSongDurationPure(gridEnd, contentEnd, userLen); -} - -function _restartPlaybackAt(t) { - if (S.audioSource) { - try { S.audioSource.stop(); } catch (_) {} - S.audioSource = null; - } - S.cursorTime = Math.max(0, Math.min(S.duration || Infinity, t)); - // Compose mode re-anchors the clock without a BufferSource — the guide/ - // click scheduler is the only sound (charrette §1.7). - if (S.audioBuffer) _startAudioSourceAtCursor(); - else _anchorTransportAtCursor(); -} - -function startPlayback() { - // Compose mode (no recording) still needs a context — for the transport - // clock and the metronome/guide voices that are its only sound. Make one - // on the play gesture; the decode path is the only other creation site. - _ensureAudioCtx(); - if (!S.audioCtx) return; // no Web Audio available at all - const composing = !S.audioBuffer; - if (composing) { - // No buffer to bound the song: the grid defines its length (§1.7). - S.duration = _composeSongDuration(); - if (!(S.duration > 0)) return; // empty grid + no content — nothing to play - } - if (S.audioCtx.state === 'suspended') S.audioCtx.resume(); - const region = _selectedLoopRegion(); - if (S.loopEnabled && region && (S.cursorTime < region.startTime || S.cursorTime >= region.endTime)) { - S.cursorTime = region.startTime; - } - if (composing) { - // No reference recording ⇒ no A/B pass to arm; just anchor the clock so - // playbackTick advances the cursor and the guide/click scheduler (the - // only sound here) fires off the grid. - _anchorTransportAtCursor(); - } else { - // Every (re)start — including seeks, which route through here — begins - // an A/B cycle on the RECORDING pass, so the user always hears the real - // thing first from a fresh position. Reset BEFORE the first tick / - // scheduler sync so _guideTick can never schedule a guide pass off a - // stale phase, and so the first-play fade (in _startAudioSourceAtCursor) - // is the last automation written to the ref gain, not clobbered by this. - _abPhase = 'recording'; - _abApplyRefGain(); - _startAudioSourceAtCursor(); - } - S.playing = true; - updatePlayIcon(); - playbackTick(); - _guideTimerSync(); -} -function stopPlayback() { - if (S.audioSource) { - try { S.audioSource.stop(); } catch (_) {} - S.audioSource = null; - } - S.playing = false; - updatePlayIcon(); - if (rafId) { cancelAnimationFrame(rafId); rafId = null; } - _guideTimerSync(); - _guideCancelVoices(); - // Restore the reference to its fader level (a stop mid-guide-pass must - // never leave the recording silently muted). - _abApplyRefGain(); -} - -function playbackTick() { - if (!S.playing) return; - S.cursorTime = _transportChartTimePure(S.playStartTime, S.playStartWall, S.audioCtx.currentTime); - const loopRestart = _recState === 'recording' - ? null - : _loopPlaybackRestartTimePure(S.cursorTime, S.barSel, S.loopEnabled, S.duration); - if (loopRestart !== null) { - // A/B compare flips its pass BEFORE the restart so the ramped - // reference mute/unmute lands with the wrap, not a frame late. - _abOnLoopWrap(); - _restartPlaybackAt(loopRestart); - updateTimeDisplay(); - // playbackTick already runs once per animation frame — paint - // synchronously rather than queueing a second rAF via draw(). - drawNow(); - rafId = requestAnimationFrame(playbackTick); - return; - } - if (S.cursorTime >= S.duration) { - // If a live MIDI recording is active, finalize it at the song end - // before resetting the cursor — otherwise chartTimeNow() keeps - // advancing past S.duration and emits notes beyond the chart. - if (_recState === 'recording') { - window.editorStopRecordMidi(); - } else { - stopPlayback(); - } - S.cursorTime = 0; - updateTimeDisplay(); // reflect the reset immediately before returning - drawNow(); - return; // stopPlayback() already cancelled rafId; don't re-schedule. - } - - // Auto-scroll to follow the playhead — unless follow is toggled off - // (Shift+L), which lets an author inspect/edit one spot while the - // song plays on. - { - const cx = timeToX(S.cursorTime); - const w = canvas ? canvas.width / DPR : 800; - const target = _followScrollTargetPure( - S.cursorTime, cx, w, S.zoom, editorFollowEnabled()); - if (target !== null) S.scrollX = _editorClampScrollX(target); - } - - updateTimeDisplay(); - drawNow(); - rafId = requestAnimationFrame(playbackTick); -} - -/* @pure:follow-scroll:start */ -// Follow-playhead scroll policy: once the cursor crosses 80% of the view, -// jump the window so the cursor sits at 30% — but only when follow is on. -// Returns the UNCLAMPED scrollX target, or null for "don't move". -function _followScrollTargetPure(cursorTime, cursorX, viewW, zoom, followOn) { - if (!followOn) return null; - if (!(cursorX > viewW * 0.8)) return null; - return cursorTime - (viewW * 0.3) / zoom; -} -/* @pure:follow-scroll:end */ - -function editorFollowEnabled() { - // Default ON — follow is today's behavior; the pref only records an - // explicit opt-out. - try { return localStorage.getItem('editorFollow') !== '0'; } - catch (_) { return true; } -} - -function _editorToggleFollow() { - const next = !editorFollowEnabled(); - try { localStorage.setItem('editorFollow', next ? '1' : '0'); } catch (_) {} - setStatus(next - ? 'Follow on — the view tracks the playhead during playback (Shift+L)' - : 'Follow off — the view stays put while the song plays (Shift+L)'); - return true; -} - -function updatePlayIcon() { - const icon = document.getElementById('editor-play-icon'); - if (!icon) return; - if (S.playing) { - icon.innerHTML = ''; - } else { - icon.innerHTML = ''; - } -} - -// ════════════════════════════════════════════════════════════════════ -// Guide claps — a percussive tick per charted event during playback, so -// authors can verify note placement by ear (charting-by-ear was silent: -// the editor had zero note sonification). Claps are scheduled by a -// setInterval lookahead loop — NOT the rAF draw loop — so audio timing -// stays sample-accurate even when draw() is saturated, and every voice -// sums through a limited master bus (hearing safety). -// ════════════════════════════════════════════════════════════════════ - -/* @pure:guide-clap:start */ -// Half-open window query over a SORTED event-time array: returns the times t -// with from <= t < to, deduplicated at 1 ms resolution so a chord stack -// (several notes at one timestamp) claps once instead of N voices stacking -// into a louder transient. -function _guideClapTimesInWindowPure(times, from, to) { - if (!Array.isArray(times) || !times.length || !(to > from)) return []; - // Binary search for the first index with times[i] >= from. - let lo = 0, hi = times.length; - while (lo < hi) { - const mid = (lo + hi) >> 1; - if (times[mid] < from) lo = mid + 1; else hi = mid; - } - const out = []; - let lastKey = null; - for (let i = lo; i < times.length && times[i] < to; i++) { - const key = Math.round(times[i] * 1000); - if (key === lastKey) continue; - lastKey = key; - out.push(times[i]); - } - return out; -} -// Map chart-seconds onto the AudioContext clock via the transport anchor -// (_startAudioSourceAtCursor records wall/chart time as the audio starts). -function _guideChartToCtxPure(chartT, playStartWall, playStartTime) { - return playStartWall + (chartT - playStartTime); -} -// Sanitize a raw event-time array before the window query, matching every -// other time-array consumer in this file (_editorJumpNote / -Beat / -Anchor): -// drop non-finite entries — a stray NaN/undefined time would reach -// osc.start(NaN) and throw inside the tick, killing clap scheduling — and -// sort ascending, which the early-terminating window scan relies on. -function _guideSanitizeTimesPure(times) { - if (!Array.isArray(times)) return []; - return times.filter(Number.isFinite).sort((a, b) => a - b); -} -// Clamp the lookahead window end to the loop-region end so no clap is -// scheduled past the boundary: the 120 ms lookahead can queue voices for -// events after the loop end before the rAF-detected wrap cancels them -// ("ghost claps" past the loop). No-op when looping is off. -function _guideWindowEndPure(rawTo, loopEnabled, loopEndTime) { - if (loopEnabled && Number.isFinite(loopEndTime)) return Math.min(rawTo, loopEndTime); - return rawTo; -} -// Metronome clicks for the beat rows in [from, to): every beat entry gets a -// click, downbeats (measure > 0) get the accent; sub-beats are measure -1. -// Same half-open window contract as the clap query so the shared scheduler -// never double-fires a beat across adjacent ticks. -function _metroClicksInWindowPure(beats, from, to) { - if (!Array.isArray(beats) || !beats.length || !(to > from)) return []; - let lo = 0, hi = beats.length; - while (lo < hi) { - const mid = (lo + hi) >> 1; - if (beats[mid].time < from) lo = mid + 1; else hi = mid; - } - const out = []; - for (let i = lo; i < beats.length && beats[i].time < to; i++) { - out.push({ t: beats[i].time, accent: beats[i].measure > 0 }); - } - return out; -} -/* @pure:guide-clap:end */ - -const GUIDE_LOOKAHEAD = 0.12; // seconds scheduled ahead of the transport -const GUIDE_TICK_MS = 25; // scheduler cadence -let _guideTimer = null; -let _guideScheduledUntil = 0; // chart-seconds watermark (exclusive) -let _guideVoices = []; // queued {osc, gain, until} for cancel-on-seek -let _guideLastFiredKey = null; // last-fired 1 ms bucket key, PERSISTED across - // ticks so a chord straddling a window boundary - // (same bucket, split by the 25 ms tick) can't - // double-fire — per-window dedupe alone resets. - -function editorGuideClapEnabled() { - try { return localStorage.getItem('editorGuideClap') === '1'; } - catch (_) { return false; } -} -function editorMetronomeEnabled() { - try { return localStorage.getItem('editorMetronome') === '1'; } - catch (_) { return false; } -} - -/* @pure:audio-mixer:start */ -// Mixer math for the 3-fader popover (recording / guide / click) and the -// edit-preview blip gating. Fader percents live in editor prefs (never the -// pack) and map linearly onto bus gain, so 100% = the bus's design ceiling -// (unity) — nothing here can boost a bus past the shipped headroom. -const MIX_DEFAULT_PCT = Object.freeze({ ref: 100, guide: 35, click: 25 }); -// Parse a stored fader percent: corrupted values clamp into [0, 100] and -// non-numeric ones fall back, so a bad pref can never blast a bus. -function _mixPctFromStoredPure(raw, fallbackPct) { - const n = parseInt(raw, 10); - if (!Number.isFinite(n)) return fallbackPct; - return Math.max(0, Math.min(100, n)); -} -function _mixGainForPctPure(pct) { - const p = Number(pct); - if (!Number.isFinite(p)) return 0; - return Math.max(0, Math.min(100, p)) / 100; -} -// First play of a session starts the recording below target and ramps up -// (~0.35 s): an unexpectedly hot recording is reached, never jumped to. -// Quiet targets keep a small audible floor so the fade is never mistaken -// for a broken/silent load. -function _mixFirstPlayStartGainPure(target) { - if (!(target > 0)) return 0; - return Math.min(target, Math.max(0.05, target * 0.3)); -} -// Rate-limit for the edit-preview blip: a group edit (set fret on N notes) -// must read as ONE cue, not a machine-gun transient. -function _mixBlipAllowedPure(nowMs, lastMs, gapMs) { - if (!Number.isFinite(lastMs)) return true; - return (nowMs - lastMs) >= gapMs; -} -// A committed drag only previews when it changed PITCH — any string delta -// (a note moved to another string sounds a different pitch) or any fret -// delta (a moved keys/piano-roll pitch, or a fret-changing drag). Time-only -// moves and marquee selects carry no string/fret delta, so they stay silent. -function _mixDragChangedPitchPure(dstrings, dfrets) { - const ds = Array.isArray(dstrings) && dstrings.some(d => d !== 0); - const df = Array.isArray(dfrets) && dfrets.some(d => d !== 0); - return ds || df; -} -/* @pure:audio-mixer:end */ - -/* @pure:audio-bus:start */ -// Guide-voice bus ONLY: the claps sum through their own gain into a limiter -// so many simultaneous voices can never spike, then to the destination. The -// reference recording deliberately does NOT pass through here — it stays on a -// transparent path straight to destination (see _startAudioSourceAtCursor) so -// the limiter never colors loud / brickwalled reference recordings, whether -// or not guide claps are ever used. -let _masterBus = null; -function _ensureMasterBus() { - if (_masterBus || !S.audioCtx) return _masterBus; - const ctx = S.audioCtx; - const guideGain = ctx.createGain(); - guideGain.gain.value = _mixGainForPctPure(_mixLoadPct().guide); - // Click sits well under the reference/guide by default (≈ -12 dB) — the - // metronome should be felt, not fought with. Both levels come from the - // mixer prefs; the defaults preserve the shipped balance. - const clickGain = ctx.createGain(); - clickGain.gain.value = _mixGainForPctPure(_mixLoadPct().click); - const limiter = ctx.createDynamicsCompressor(); - limiter.threshold.value = -1; - limiter.knee.value = 0; - limiter.ratio.value = 20; - limiter.attack.value = 0.003; - limiter.release.value = 0.25; - guideGain.connect(limiter); - clickGain.connect(limiter); - limiter.connect(ctx.destination); - _masterBus = { guideGain, clickGain, limiter }; - return _masterBus; -} - -// Fader percents, cached so audio paths never read localStorage -// synchronously mid-schedule; seeded once, kept in sync by _mixSetBusGain. -let _mixPctCache = null; -function _mixLoadPct() { - if (_mixPctCache) return _mixPctCache; - let ref = null, guide = null, click = null; - try { - ref = localStorage.getItem('editorMixRef'); - guide = localStorage.getItem('editorMixGuide'); - click = localStorage.getItem('editorMixClick'); - } catch (_) {} - _mixPctCache = { - ref: _mixPctFromStoredPure(ref, MIX_DEFAULT_PCT.ref), - guide: _mixPctFromStoredPure(guide, MIX_DEFAULT_PCT.guide), - click: _mixPctFromStoredPure(click, MIX_DEFAULT_PCT.click), - }; - return _mixPctCache; -} - -// Recording volume node: a TRANSPARENT gain straight to destination — the -// reference still never sums through the guide limiter (see the bus comment -// above). This only adds user volume control; unity by default. -let _refGain = null; -function _ensureRefGain() { - if (_refGain || !S.audioCtx) return _refGain; - _refGain = S.audioCtx.createGain(); - _refGain.gain.value = _mixGainForPctPure(_mixLoadPct().ref); - _refGain.connect(S.audioCtx.destination); - return _refGain; -} - -// First-play fade (hearing safety): once per loaded recording, the -// reference ramps from a reduced level up to its fader target as playback -// starts. Re-armed by _mixResetFirstPlay() on every new/replaced recording -// (see loadAudio()) — the ramp guards against an unexpectedly hot recording, -// so it must not go stale after the very first song of a session. -let _mixFirstPlayDone = false; -function _mixApplyFirstPlayFade() { - if (_mixFirstPlayDone || !_refGain || !S.audioCtx) return; - _mixFirstPlayDone = true; - const target = _mixGainForPctPure(_mixLoadPct().ref); - const now = S.audioCtx.currentTime; - _refGain.gain.setValueAtTime(_mixFirstPlayStartGainPure(target), now); - _refGain.gain.linearRampToValueAtTime(target, now + 0.35); -} - -// Re-arm the first-play fade: called whenever a new reference recording is -// decoded (loadCDLC, create/import, and replace-audio all funnel through -// loadAudio()) so each new recording gets the hearing-safety ramp, not just -// the first one of the screen's lifetime. -function _mixResetFirstPlay() { - _mixFirstPlayDone = false; -} - -// Apply a fader move: persist the pref and ramp the live node (~20 ms -// smoothing) — a gain change is never a stepped jump mid-audio. -function _mixSetBusGain(bus, pct) { - const key = bus === 'ref' ? 'editorMixRef' - : bus === 'guide' ? 'editorMixGuide' : 'editorMixClick'; - const p = _mixPctFromStoredPure(String(pct), MIX_DEFAULT_PCT[bus]); - _mixLoadPct()[bus] = p; - try { localStorage.setItem(key, String(p)); } catch (_) {} - const node = bus === 'ref' ? _refGain - : bus === 'guide' ? (_masterBus && _masterBus.guideGain) - : (_masterBus && _masterBus.clickGain); - if (node && S.audioCtx) { - // The recording fader must never un-mute an active A/B guide pass: - // route ref moves through the A/B-aware target so a nudge ramps to - // the fresh level on a recording pass but stays muted on a guide - // pass. Guarded — the @pure:audio-bus test sandbox has no - // _abApplyRefGain, where this falls back to the plain fader ramp. - if (bus === 'ref' && typeof _abApplyRefGain === 'function') { - _abApplyRefGain(); - } else { - node.gain.setTargetAtTime(_mixGainForPctPure(p), S.audioCtx.currentTime, 0.02); - } - } - return p; -} - -function editorEditBlipEnabled() { - try { return localStorage.getItem('editorEditBlip') !== '0'; } - catch (_) { return true; } -} - -// Edit-preview blip: a soft confirmation tick on note ADD and PITCH change -// only (never marquee/time-only moves). It sums straight into the shared -// limiter — NOT through the guide fader — so muting guide claps never also -// silences the edit cue, while the limiter still tames it. It skips when the -// context isn't running — an edit must never resume audio — and is pitched -// apart from the 1750 Hz guide clap so the two read as different cues. -let _mixLastBlipMs = null; -function _editBlipAt() { - if (!editorEditBlipEnabled()) return; - if (!S.audioCtx || S.audioCtx.state !== 'running') return; - const bus = _ensureMasterBus(); - if (!bus) return; - const nowMs = Date.now(); - if (!_mixBlipAllowedPure(nowMs, _mixLastBlipMs, 60)) return; - _mixLastBlipMs = nowMs; - const ctx = S.audioCtx; - const when = ctx.currentTime; - const osc = ctx.createOscillator(); - osc.type = 'triangle'; - osc.frequency.value = 1320; - const g = ctx.createGain(); - g.gain.setValueAtTime(0.0001, when); - g.gain.exponentialRampToValueAtTime(0.5, when + 0.002); - g.gain.exponentialRampToValueAtTime(0.0001, when + 0.04); - osc.connect(g); - g.connect(bus.limiter); - osc.start(when); - osc.stop(when + 0.05); - _guideVoices.push({ osc, gain: g, until: when + 0.05 }); - // Same bounded-bookkeeping rule as the scheduler tick. - if (_guideVoices.length > 64) { - const nowCtx = ctx.currentTime; - _guideVoices = _guideVoices.filter(v => v.until > nowCtx); - } -} - -// Audition one pitch for the keyboard gutter (click a piano key → hear it). -// A gentle, hearing-safe voice through the master limiter (soft attack, ~0.28 -// peak, ~320 ms decay) — the same envelope shape as the edit blip but pitched -// and a touch longer, so it reads as a note rather than a tick. No-op when the -// context isn't running (autoplay-gated) or the pitch is out of audible range. -function _auditionPitch(midi) { - if (!S.audioCtx || S.audioCtx.state !== 'running') return; - const freq = midiToFreq(midi); - if (!(freq > 0) || freq > 20000) return; - const bus = _ensureMasterBus(); - if (!bus) return; - const ctx = S.audioCtx; - const when = ctx.currentTime; - const osc = ctx.createOscillator(); - osc.type = 'triangle'; - osc.frequency.value = freq; - const g = ctx.createGain(); - g.gain.setValueAtTime(0.0001, when); - g.gain.exponentialRampToValueAtTime(0.28, when + 0.006); - g.gain.exponentialRampToValueAtTime(0.0001, when + 0.32); - osc.connect(g); - g.connect(bus.limiter); - osc.start(when); - osc.stop(when + 0.34); - _guideVoices.push({ osc, gain: g, until: when + 0.34 }); - if (_guideVoices.length > 64) { - const nowCtx = ctx.currentTime; - _guideVoices = _guideVoices.filter(v => v.until > nowCtx); - } -} -/* @pure:audio-bus:end */ - -// Event times for the active editing surface: the drum grid claps drum hits, -// every other view claps the current arrangement's (time-sorted) notes. -function _guideSourceTimes() { - if (S.drumEditMode) { - const hits = (S.drumTab && Array.isArray(S.drumTab.hits)) ? S.drumTab.hits : []; - return _guideSanitizeTimesPure(hits.map(h => h.t)); - } - if (!S.arrangements.length) return []; - return _guideSanitizeTimesPure(notes().map(n => n.time)); -} - -function _guideClapVoiceAt(when) { - const bus = _ensureMasterBus(); - if (!bus) return; - const ctx = S.audioCtx; - const osc = ctx.createOscillator(); - osc.type = 'triangle'; - osc.frequency.value = 1750; - const g = ctx.createGain(); - // Soft tick: 3 ms ramp in (never a 0 ms transient) and ~45 ms exponential - // decay — a locatable placement cue without startle. - g.gain.setValueAtTime(0.0001, when); - g.gain.exponentialRampToValueAtTime(0.8, when + 0.003); - g.gain.exponentialRampToValueAtTime(0.0001, when + 0.048); - osc.connect(g); - g.connect(bus.guideGain); - osc.start(when); - osc.stop(when + 0.06); - _guideVoices.push({ osc, gain: g, until: when + 0.06 }); -} - -// Metronome click: a band-limited soft pip. The accent (downbeat) is -// differentiated mainly by PITCH (~1000 vs ~800 Hz) with only a small level -// delta — the hearing-safe way to accent, rather than a louder transient. -function _metroClickVoiceAt(when, accent) { - const bus = _ensureMasterBus(); - if (!bus) return; - const ctx = S.audioCtx; - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.value = accent ? 1000 : 800; - const g = ctx.createGain(); - g.gain.setValueAtTime(0.0001, when); - g.gain.exponentialRampToValueAtTime(accent ? 0.9 : 0.68, when + 0.002); - g.gain.exponentialRampToValueAtTime(0.0001, when + 0.04); - osc.connect(g); - g.connect(bus.clickGain); - osc.start(when); - osc.stop(when + 0.05); - _guideVoices.push({ osc, gain: g, until: when + 0.05 }); -} - -// Cancel every queued-but-unfinished clap — stale voices would otherwise -// fire at their pre-seek positions after a loop wrap or scrub. -function _guideCancelVoices() { - for (const v of _guideVoices) { - try { v.osc.stop(); } catch (_) {} - try { v.gain.disconnect(); } catch (_) {} - } - _guideVoices = []; -} - -function _guideResetSchedule() { - _guideCancelVoices(); - _guideScheduledUntil = S.cursorTime || 0; - _guideLastFiredKey = null; // a seek/wrap breaks cross-tick dedupe continuity -} - -function _guideTick() { - // A/B overrides the claps pref while active: guide passes clap even - // with the pref off; recording passes stay clean even with it on. - const claps = _abClapsEnabledPure(_abActive(), _abPhase, editorGuideClapEnabled()); - const metro = editorMetronomeEnabled(); - if (!S.playing || !S.audioCtx || (!claps && !metro)) return; - const nowChart = _transportChartTimePure(S.playStartTime, S.playStartWall, S.audioCtx.currentTime); - // Clamp the lookahead end to the loop-region end while looping, so no clap - // is scheduled past the boundary before the rAF wrap cancels the window. - const loopRegion = S.loopEnabled ? _normalizeLoopRegionPure(S.barSel, S.duration) : null; - const to = _guideWindowEndPure( - nowChart + GUIDE_LOOKAHEAD, !!loopRegion, loopRegion ? loopRegion.endTime : NaN); - // If the timer stalled (hidden tab), skip events that are already in the - // past rather than machine-gunning them late; 5 ms of grace keeps an - // event exactly at the cursor audible. - const from = Math.max(_guideScheduledUntil, nowChart - 0.005); - if (to <= from) return; - if (claps) { - const times = _guideClapTimesInWindowPure(_guideSourceTimes(), from, to); - for (const t of times) { - // Cross-tick dedupe: skip an event in the same 1 ms bucket as the last - // clap already fired in a previous window (chord split by the boundary). - const key = Math.round(t * 1000); - if (key === _guideLastFiredKey) continue; - _guideLastFiredKey = key; - _guideClapVoiceAt(_guideChartToCtxPure(t, S.playStartWall, S.playStartTime)); - } - } - if (metro) { - const clicks = _metroClicksInWindowPure(S.beats || [], from, to); - for (const c of clicks) { - _metroClickVoiceAt( - _guideChartToCtxPure(c.t, S.playStartWall, S.playStartTime), c.accent); - } - } - _guideScheduledUntil = to; - // Drop bookkeeping for voices that already finished (bounded memory). - if (_guideVoices.length > 64) { - const nowCtx = S.audioCtx.currentTime; - _guideVoices = _guideVoices.filter(v => v.until > nowCtx); - } -} - -// ── Loop A/B compare — the ear-training loop ───────────────────────── -// While looping, alternate each pass between the RECORDING (reference -// audible, claps off) and the GUIDE (reference muted via the mixer's -// transparent ref gain, claps on) so a charter can hear what they charted -// against what the artist played, one pass apart. Session-only state — -// deliberately not persisted: silently muting the recording on a later -// session would read as a playback bug. - -/* @pure:loop-ab:start */ -// Do claps schedule this tick? A/B overrides the claps pref while active: -// guide passes clap even with the pref off, recording passes stay clean -// even with it on. -function _abClapsEnabledPure(abActive, phase, clapsPref) { - return abActive ? phase === 'guide' : clapsPref; -} -function _abNextPhasePure(phase) { - return phase === 'guide' ? 'recording' : 'guide'; -} -// The reference gain target: muted only during an ACTIVE A/B guide pass -// while playing; every other state restores the mixer fader's value. -function _abRefTargetPure(abActive, playing, phase, faderGain) { - return (abActive && playing && phase === 'guide') ? 0 : faderGain; -} -/* @pure:loop-ab:end */ - -let _abOn = false; -let _abPhase = 'recording'; // every play starts by hearing the real thing - -// A/B compares the recording against the guide — meaningless with no reference -// buffer (compose mode), where it would only gate half of each loop's claps to -// silence. Require a buffer so compose loops keep every clap. -function _abActive() { return _abOn && !!S.loopEnabled && !!S.audioBuffer; } - -function _abApplyRefGain() { - const rg = _ensureRefGain(); - if (!rg || !S.audioCtx) return; - const target = _abRefTargetPure( - _abActive(), !!S.playing, _abPhase, - _mixGainForPctPure(_mixLoadPct().ref)); - // Same ~20 ms ramp as every mixer move — a phase flip is never a pop. - rg.gain.setTargetAtTime(target, S.audioCtx.currentTime, 0.02); -} - -function _abOnLoopWrap() { - if (!_abActive()) return; - _abPhase = _abNextPhasePure(_abPhase); - _abApplyRefGain(); - setStatus(_abPhase === 'guide' - ? 'A/B: guide pass (recording muted)' - : 'A/B: recording pass'); -} - -function _refreshLoopABBtn() { - const btn = document.getElementById('editor-loop-ab-btn'); - if (!btn) return; - const region = _selectedLoopRegion(); - btn.disabled = !region; - btn.classList.toggle('bg-accent', _abOn); - btn.classList.toggle('hover:bg-accent-light', _abOn); - btn.classList.toggle('bg-dark-600', !_abOn); - btn.classList.toggle('hover:bg-dark-500', !_abOn); - btn.setAttribute('aria-pressed', _abOn ? 'true' : 'false'); - btn.title = region - ? 'A/B compare: each loop pass alternates — recording, then guide claps only (Alt+B)' - : 'Set a loop region first — A/B alternates recording and guide per pass'; -} - -function _editorToggleLoopAB() { - if (!_abOn && !_selectedLoopRegion()) { - setStatus('Set a loop region first — A/B alternates recording and guide per pass'); - return true; - } - _abOn = !_abOn; - _abPhase = 'recording'; - if (_abOn && !S.loopEnabled && _selectedLoopRegion()) { - // A/B is meaningless without looping — arm the loop exactly like the - // Loop button, including the seek into the region when the cursor - // sits outside it, so A/B never rides a pre-loop stretch of audio. - _setLoopRegionEnabled(true); - } - _abApplyRefGain(); - _refreshLoopABBtn(); - _guideTimerSync(); // guide passes need the scheduler even with claps off - setStatus(_abOn - ? 'Loop A/B on — first pass plays the recording, the next plays only the guide claps' - : 'Loop A/B off'); - return true; -} -window.editorToggleLoopAB = _editorToggleLoopAB; - -// Start/stop the scheduler to match "playing AND enabled". Called from -// startPlayback/stopPlayback and from the toggle (mid-play enable works). -function _guideTimerSync() { - const want = S.playing - && (editorGuideClapEnabled() || editorMetronomeEnabled() || _abActive()); - if (want && !_guideTimer) { - _guideScheduledUntil = _transportChartTimePure( - S.playStartTime, S.playStartWall, S.audioCtx.currentTime); - _guideTimer = setInterval(_guideTick, GUIDE_TICK_MS); - _guideTick(); // fill the first window now, not one tick late - } else if (!want && _guideTimer) { - clearInterval(_guideTimer); - _guideTimer = null; - } -} - -function _refreshGuideBtn() { - const btn = document.getElementById('editor-guide-btn'); - if (!btn) return; - const on = editorGuideClapEnabled(); - btn.classList.toggle('bg-accent', on); - btn.classList.toggle('hover:bg-accent-light', on); - btn.classList.toggle('bg-dark-600', !on); - btn.classList.toggle('hover:bg-dark-500', !on); - btn.setAttribute('aria-pressed', on ? 'true' : 'false'); -} - -function _editorToggleGuideClap() { - const next = !editorGuideClapEnabled(); - try { localStorage.setItem('editorGuideClap', next ? '1' : '0'); } catch (_) {} - _refreshGuideBtn(); - _guideTimerSync(); - setStatus(next - ? 'Guide claps on — charted notes tick during playback (C toggles)' - : 'Guide claps off'); - return true; -} -window.editorToggleGuideClap = _editorToggleGuideClap; -_refreshGuideBtn(); - -function _refreshMetronomeBtn() { - const btn = document.getElementById('editor-metronome-btn'); - if (!btn) return; - const on = editorMetronomeEnabled(); - btn.classList.toggle('bg-accent', on); - btn.classList.toggle('hover:bg-accent-light', on); - btn.classList.toggle('bg-dark-600', !on); - btn.classList.toggle('hover:bg-dark-500', !on); - btn.setAttribute('aria-pressed', on ? 'true' : 'false'); -} - -function _editorToggleMetronome() { - const next = !editorMetronomeEnabled(); - try { localStorage.setItem('editorMetronome', next ? '1' : '0'); } catch (_) {} - _refreshMetronomeBtn(); - _guideTimerSync(); - setStatus(next - ? 'Metronome on — clicks follow the beat grid, accented on downbeats' - : 'Metronome off'); - return true; -} -window.editorToggleMetronome = _editorToggleMetronome; -_refreshMetronomeBtn(); - -// ── Audio mixer popover ────────────────────────────────────────────── -function _refreshMixerBtn() { - const btn = document.getElementById('editor-mixer-btn'); - if (!btn) return; - const panel = document.getElementById('editor-audio-mixer'); - const open = !!(panel && !panel.classList.contains('hidden')); - btn.classList.toggle('bg-accent', open); - btn.classList.toggle('hover:bg-accent-light', open); - btn.classList.toggle('bg-dark-600', !open); - btn.classList.toggle('hover:bg-dark-500', !open); - btn.setAttribute('aria-pressed', open ? 'true' : 'false'); -} - -function _refreshMixerUI() { - const pcts = _mixLoadPct(); - for (const [bus, id] of [['ref', 'editor-mix-ref'], ['guide', 'editor-mix-guide'], ['click', 'editor-mix-click']]) { - const slider = document.getElementById(id); - const label = document.getElementById(id + '-val'); - if (slider) slider.value = String(pcts[bus]); - if (label) label.textContent = pcts[bus] + '%'; - } - const blip = document.getElementById('editor-mix-blip'); - if (blip) blip.checked = editorEditBlipEnabled(); -} - -function _editorToggleMixer(force) { - const panel = document.getElementById('editor-audio-mixer'); - if (!panel) return false; - const show = force === undefined ? panel.classList.contains('hidden') : !!force; - panel.classList.toggle('hidden', !show); - if (show) _refreshMixerUI(); - _refreshMixerBtn(); - return true; -} -window.editorToggleMixer = _editorToggleMixer; - -window.editorSetMixLevel = (bus, val) => { - if (bus !== 'ref' && bus !== 'guide' && bus !== 'click') return; - const p = _mixSetBusGain(bus, val); - const label = document.getElementById( - (bus === 'ref' ? 'editor-mix-ref' : bus === 'guide' ? 'editor-mix-guide' : 'editor-mix-click') + '-val'); - if (label) label.textContent = p + '%'; -}; - -window.editorSetEditBlip = (on) => { - try { localStorage.setItem('editorEditBlip', on ? '1' : '0'); } catch (_) {} - setStatus(on - ? 'Edit blip on — a soft tick confirms note adds and pitch changes' - : 'Edit blip off'); -}; -_refreshMixerBtn(); function updateMeasureDisplay() { const el = document.getElementById('editor-measure-display'); @@ -4745,9 +3700,7 @@ async function loadCDLC(filename) { // audio + UI too: clearing the flags alone would leave a guide-pass // mute on the ref gain and stale A/B button styling until the next // incidental control refresh. - _abOn = false; - _abPhase = 'recording'; - _abApplyRefGain(); + _abDisarm(); _guideTimerSync(); _updateLoopRegionControls(); // Abandon any in-progress drag — the global mouse handlers act on @@ -6641,6 +5594,7 @@ function init() { // src/create.js's global 'input' listener. It used to be a top-level // statement in this file; a module must not have import-time side effects. initCreate(); + initAudio(); // Observe screen visibility for resize + the entry landing. Held in // _editorScreenObs so the teardown can disconnect it on re-injection. diff --git a/tests/audio_mixer.test.js b/tests/audio_mixer.test.js index ab84ceca..818deff4 100644 --- a/tests/audio_mixer.test.js +++ b/tests/audio_mixer.test.js @@ -15,7 +15,7 @@ const fs = require('fs'); const path = require('path'); const assert = require('assert'); -const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'audio.js'), 'utf8'); function extract(name) { const re = new RegExp( @@ -25,7 +25,7 @@ function extract(name) { console.error(`FAIL: @pure:${name} block not found in src/main.js`); process.exit(1); } - return m[0]; + return m[0].replace(/^export\s+/gm, ''); } const mixBlock = extract('audio-mixer'); diff --git a/tests/boot_teardown.test.js b/tests/boot_teardown.test.js index d84ba136..05a052e2 100644 --- a/tests/boot_teardown.test.js +++ b/tests/boot_teardown.test.js @@ -122,22 +122,23 @@ function runTeardown(over) { const state = Object.assign({ _globalListeners: { removeAll() {} }, S: {}, - rafId: null, _editorScreenObs: null, _v3TopbarWatch: null, _v3LayoutObs: null, _bootPollInterval: null, - cancelAnimationFrame: () => {}, clearInterval: (id) => { cleared.push(id); }, + // playback + rAF teardown moved to src/audio.js; the closure delegates to + // it now. Its own effects are covered by the audio suite. + teardownAudio: () => {}, }, over); const fn = new Function( - '_globalListeners', 'S', 'rafId', '_editorScreenObs', '_v3TopbarWatch', - '_v3LayoutObs', '_bootPollInterval', 'cancelAnimationFrame', 'clearInterval', + '_globalListeners', 'S', '_editorScreenObs', '_v3TopbarWatch', + '_v3LayoutObs', '_bootPollInterval', 'clearInterval', 'teardownAudio', tm[1] + '\nreturn { _v3LayoutObs, _bootPollInterval };' ); - const out = fn(state._globalListeners, state.S, state.rafId, state._editorScreenObs, + const out = fn(state._globalListeners, state.S, state._editorScreenObs, state._v3TopbarWatch, state._v3LayoutObs, state._bootPollInterval, - state.cancelAnimationFrame, state.clearInterval); + state.clearInterval, state.teardownAudio); return { out, cleared }; } diff --git a/tests/compose_transport.test.mjs b/tests/compose_transport.test.mjs index de429dbd..5cb91d13 100644 --- a/tests/compose_transport.test.mjs +++ b/tests/compose_transport.test.mjs @@ -32,7 +32,10 @@ import { _composeSongDurationPure, _transportChartTimePure } from '../src/transp import fs from 'node:fs'; import { timeOf } from '../src/beats.js'; -const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8'); +// _composeSongDuration / _anchorTransportAtCursor / the guide-tick helpers moved +// to src/audio.js; the pures (_transportChartTimePure, _composeSongDurationPure) +// are real imports from src/transport.js. +const src = fs.readFileSync(new URL('../src/audio.js', import.meta.url), 'utf8'); function extractBlock(name) { const m = src.match(new RegExp('/\\* @pure:' + name + ':start \\*/[\\s\\S]*?/\\* @pure:' + name + ':end \\*/')); diff --git a/tests/follow_toggle.test.js b/tests/follow_toggle.test.js index 54dfea9a..b29c9ac3 100644 --- a/tests/follow_toggle.test.js +++ b/tests/follow_toggle.test.js @@ -13,12 +13,13 @@ const fs = require('fs'); const path = require('path'); const assert = require('assert'); -const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); -const m = src.match(/\/\* @pure:follow-scroll:start \*\/[\s\S]*?\/\* @pure:follow-scroll:end \*\//); -if (!m) { +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'audio.js'), 'utf8'); +const _m0 = src.match(/\/\* @pure:follow-scroll:start \*\/[\s\S]*?\/\* @pure:follow-scroll:end \*\//); +if (!_m0) { console.error('FAIL: @pure:follow-scroll block not found in src/main.js'); process.exit(1); } +const m = [_m0[0].replace(/^export\s+/gm, '')]; const { _followScrollTargetPure } = new Function( '"use strict";' + m[0] + '\nreturn { _followScrollTargetPure };' )(); diff --git a/tests/guide_clap.test.js b/tests/guide_clap.test.js index 6896c23b..11035572 100644 --- a/tests/guide_clap.test.js +++ b/tests/guide_clap.test.js @@ -24,12 +24,13 @@ const fs = require('fs'); const path = require('path'); const assert = require('assert'); -const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); -const m = src.match(/\/\* @pure:guide-clap:start \*\/[\s\S]*?\/\* @pure:guide-clap:end \*\//); -if (!m) { +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'audio.js'), 'utf8'); +const _m0 = src.match(/\/\* @pure:guide-clap:start \*\/[\s\S]*?\/\* @pure:guide-clap:end \*\//); +if (!_m0) { console.error('FAIL: @pure:guide-clap block not found in src/main.js'); process.exit(1); } +const m = [_m0[0].replace(/^export\s+/gm, '')]; const { _guideClapTimesInWindowPure, diff --git a/tests/keyboard_gutter.test.mjs b/tests/keyboard_gutter.test.mjs index aa758018..498ca47f 100644 --- a/tests/keyboard_gutter.test.mjs +++ b/tests/keyboard_gutter.test.mjs @@ -12,7 +12,7 @@ import assert from 'node:assert'; import fs from 'node:fs'; import { _inKeyboardGutterPure, midiToFreq } from '../src/keys.js'; -const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8'); +const src = fs.readFileSync(new URL('../src/audio.js', import.meta.url), 'utf8'); function extractFn(name) { const start = src.indexOf('function ' + name); @@ -21,7 +21,7 @@ function extractFn(name) { let depth = 0; for (let i = open; i < src.length; i++) { if (src[i] === '{') depth++; - else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1).replace(/^export\s+/gm, ''); } throw new Error('unbalanced braces extracting ' + name); } diff --git a/tests/loop_ab.test.js b/tests/loop_ab.test.js index 70323462..f97cefee 100644 --- a/tests/loop_ab.test.js +++ b/tests/loop_ab.test.js @@ -12,12 +12,16 @@ const fs = require('fs'); const path = require('path'); const assert = require('assert'); -const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); -const m = src.match(/\/\* @pure:loop-ab:start \*\/[\s\S]*?\/\* @pure:loop-ab:end \*\//); -if (!m) { +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'audio.js'), 'utf8'); +// _setLoopRegionEnabled stayed in main.js (it drives the loop-region UI, not the +// audio engine); slice it from there when a case needs the real disarm path. +const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); +const _m0 = src.match(/\/\* @pure:loop-ab:start \*\/[\s\S]*?\/\* @pure:loop-ab:end \*\//); +if (!_m0) { console.error('FAIL: @pure:loop-ab block not found in src/main.js'); process.exit(1); } +const m = [_m0[0].replace(/^export\s+/gm, '')]; const { _abClapsEnabledPure, _abNextPhasePure, _abRefTargetPure } = new Function( '"use strict";' + m[0] + '\nreturn { _abClapsEnabledPure, _abNextPhasePure, _abRefTargetPure };' @@ -89,16 +93,16 @@ function extractBlock(name) { '/\\* @pure:' + name + ':start \\*/[\\s\\S]*?/\\* @pure:' + name + ':end \\*/'); const mm = src.match(re); if (!mm) { console.error('FAIL: @pure:' + name + ' block not found'); process.exit(1); } - return mm[0]; + return mm[0].replace(/^export\s+/gm, ''); } // The loose A/B runtime (state + _abActive/_abApplyRefGain/_abOnLoopWrap/ // _refreshLoopABBtn/_editorToggleLoopAB) is not a @pure block — slice it by // its stable endpoints. const abRuntime = (() => { const mm = src.match( - /let _abOn = false;[\s\S]*?window\.editorToggleLoopAB = _editorToggleLoopAB;/); + /let _abOn = false;[\s\S]*?\n\/\/ window\.editorToggleLoopAB re-attached in main\.js/); if (!mm) { console.error('FAIL: A/B runtime slice not found'); process.exit(1); } - return mm[0]; + return mm[0].replace(/^export\s+/gm, ''); })(); function stubParam() { @@ -150,12 +154,22 @@ function buildAB(opts) { // drives the true loop-disarm path (for the "restore ref on disable" test). let loopArm = ''; if (opts.withLoopArm) { - const mm = src.match(/function _setLoopRegionEnabled\(enabled\) \{[\s\S]*?\n\}/); + const mm = mainSrc.match(/function _setLoopRegionEnabled\(enabled\) \{[\s\S]*?\n\}/); if (!mm) { console.error('FAIL: _setLoopRegionEnabled not found'); process.exit(1); } loopArm = '\n' + mm[0]; } + // The A/B runtime reaches main.js through `host` now; map its two methods to + // the same spies the injected params used to be. + const host = { + selectedLoopRegion: () => region, + setLoopRegionEnabled: (enabled) => { + spies.setLoopRegionEnabled.push(enabled); S.loopEnabled = !!enabled; + }, + draw: () => {}, drawNow: () => {}, updateTimeDisplay: () => {}, + editorClampScrollX: (x) => x, editorApplyScrollBounds: () => {}, + }; const env = new Function( - 'S', 'localStorage', 'document', 'window', '_guideVoices', + 'S', 'localStorage', 'document', 'window', '_guideVoices', 'host', '_selectedLoopRegion', '_setLoopRegionEnabled', '_updateLoopRegionControls', '_guideTimerSync', 'setStatus', '_editorSeekToTime', 'draw', '"use strict";' + mixBlock + '\n' + busBlock + '\n' + abPure + '\n' + abRuntime + loopArm @@ -164,7 +178,7 @@ function buildAB(opts) { + ' setPhase: (p) => { _abPhase = p; }, setOn: (v) => { _abOn = v; },' + ' getOn: () => _abOn, getPhase: () => _abPhase };' )( - S, stubLocalStorage(), doc, win, [], + S, stubLocalStorage(), doc, win, [], host, () => region, (enabled) => { spies.setLoopRegionEnabled.push(enabled); S.loopEnabled = !!enabled; }, () => {}, // _updateLoopRegionControls (pre-fix arming path uses this) diff --git a/tests/metronome_click.test.js b/tests/metronome_click.test.js index 0926f46f..adc3a6c3 100644 --- a/tests/metronome_click.test.js +++ b/tests/metronome_click.test.js @@ -14,12 +14,13 @@ const fs = require('fs'); const path = require('path'); const assert = require('assert'); -const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); -const m = src.match(/\/\* @pure:guide-clap:start \*\/[\s\S]*?\/\* @pure:guide-clap:end \*\//); -if (!m) { +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'audio.js'), 'utf8'); +const _m0 = src.match(/\/\* @pure:guide-clap:start \*\/[\s\S]*?\/\* @pure:guide-clap:end \*\//); +if (!_m0) { console.error('FAIL: @pure:guide-clap block not found in src/main.js'); process.exit(1); } +const m = [_m0[0].replace(/^export\s+/gm, '')]; const { _metroClicksInWindowPure } = new Function( '"use strict";' + m[0] + '\nreturn { _metroClicksInWindowPure };' diff --git a/tests/onset_snap.test.js b/tests/onset_snap.test.js index 315a51cf..09334bdb 100644 --- a/tests/onset_snap.test.js +++ b/tests/onset_snap.test.js @@ -19,13 +19,16 @@ const fs = require('fs'); const path = require('path'); const assert = require('assert'); -const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); +// @pure:onset-snap moved to src/audio.js; snapTime is still in src/main.js. +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'audio.js'), 'utf8'); +const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); -const m = src.match(/\/\* @pure:onset-snap:start \*\/[\s\S]*?\/\* @pure:onset-snap:end \*\//); -if (!m) { +const _m0 = src.match(/\/\* @pure:onset-snap:start \*\/[\s\S]*?\/\* @pure:onset-snap:end \*\//); +if (!_m0) { console.error('FAIL: @pure:onset-snap block not found in src/main.js'); process.exit(1); } +const m = [_m0[0].replace(/^export\s+/gm, '')]; const { _nearestOnsetTimePure } = new Function( '"use strict";' + m[0] + '\nreturn { _nearestOnsetTimePure };' )(); @@ -33,13 +36,13 @@ const { _nearestOnsetTimePure } = new Function( // Extract snapTime by name (brace matching — the tempo_beat_drag harness) and // inject its free identifiers so we can drive the onset-vs-grid routing. function extractFn(name) { - const start = src.indexOf('function ' + name); + const start = mainSrc.indexOf('function ' + name); assert.ok(start >= 0, `function ${name} must exist`); - const open = src.indexOf('{', start); + const open = mainSrc.indexOf('{', start); let depth = 0; - for (let i = open; i < src.length; i++) { - if (src[i] === '{') depth++; - else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + for (let i = open; i < mainSrc.length; i++) { + if (mainSrc[i] === '{') depth++; + else if (mainSrc[i] === '}' && --depth === 0) return mainSrc.slice(start, i + 1).replace(/^export\s+/gm, ''); } throw new Error(`unbalanced braces extracting ${name}`); } diff --git a/tests/onset_strip.test.js b/tests/onset_strip.test.js index 9bb4c8ee..f5b6669e 100644 --- a/tests/onset_strip.test.js +++ b/tests/onset_strip.test.js @@ -15,12 +15,13 @@ const fs = require('fs'); const path = require('path'); const assert = require('assert'); -const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); -const m = src.match(/\/\* @pure:onset-strip:start \*\/[\s\S]*?\/\* @pure:onset-strip:end \*\//); -if (!m) { +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'audio.js'), 'utf8'); +const _m0 = src.match(/\/\* @pure:onset-strip:start \*\/[\s\S]*?\/\* @pure:onset-strip:end \*\//); +if (!_m0) { console.error('FAIL: @pure:onset-strip block not found in src/main.js'); process.exit(1); } +const m = [_m0[0].replace(/^export\s+/gm, '')]; const { _onsetTimesFromPeaksPure } = new Function( '"use strict";' + m[0] + '\nreturn { _onsetTimesFromPeaksPure };' )(); diff --git a/tests/waveform_peaks.test.js b/tests/waveform_peaks.test.js index a03cf65e..f553b141 100644 --- a/tests/waveform_peaks.test.js +++ b/tests/waveform_peaks.test.js @@ -24,7 +24,7 @@ function extractFn(src, name) { throw new Error(`unbalanced braces extracting ${name}`); } -const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8'); +const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'audio.js'), 'utf8'); const _buildWaveformPeaks = new Function( '"use strict";' + extractFn(src, '_buildWaveformPeaks') + '\nreturn _buildWaveformPeaks;')(); From 72b801a3bbaeddd8fb2e6fe23741f917d7c8bfd9 Mon Sep 17 00:00:00 2001 From: byrongamatos Date: Fri, 10 Jul 2026 03:52:17 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(editor):=20address=20audio=20review=20?= =?UTF-8?q?=E2=80=94=20self-contained=20=5FabDisarm,=20stale=20test=20path?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot + CodeRabbit, on #179. - CodeRabbit (Major, audio.js): _abDisarm() now calls _guideTimerSync() itself. Disarming A/B flips _guideTimerSync's "want" (which includes _abActive()), so leaving each caller to remember it invited exactly the kind of stranded-timer leak the teardown fix already chased. The one caller that did call it explicitly (the song-change reset) drops the now-redundant call. - Copilot (loop_ab.js): the A/B runtime slice regex matched a bare `let _abOn` but the code is now an exported binding. It worked by substring luck; made the `export` prefix optional-explicit so it can't silently mis-slice. - CodeRabbit (x3): metronome_click / onset_snap / onset_strip printed "not found in src/main.js" in their block-missing guard, but they read src/audio.js now. Corrected. NOT changed, with reasons: - loadAudio() swallowing decode errors (CodeRabbit Major, Copilot): real, but PRE-EXISTING and verbatim-moved. A decode failure leaving the prior song's buffer is a latent bug, and fixing it means changing loadCDLC's flow and deciding what the UI shows on failure — a behaviour change that belongs in its own PR, not a mechanical extraction. node --test 90/90, npm run lint 0 errors, verify_audio.py passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/audio.js | 3 +++ src/main.js | 3 +-- tests/loop_ab.test.js | 2 +- tests/metronome_click.test.js | 2 +- tests/onset_snap.test.js | 2 +- tests/onset_strip.test.js | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/audio.js b/src/audio.js index b981be28..67c36e4c 100644 --- a/src/audio.js +++ b/src/audio.js @@ -921,6 +921,9 @@ export function _abDisarm() { _abOn = false; _abPhase = 'recording'; _abApplyRefGain(); + // Disarming A/B can flip _guideTimerSync's "want" (it includes _abActive()), + // so re-sync here rather than leaving each caller to remember (CodeRabbit). + _guideTimerSync(); } export function _abApplyRefGain() { diff --git a/src/main.js b/src/main.js index 20c2a778..c21c0578 100644 --- a/src/main.js +++ b/src/main.js @@ -3700,8 +3700,7 @@ async function loadCDLC(filename) { // audio + UI too: clearing the flags alone would leave a guide-pass // mute on the ref gain and stale A/B button styling until the next // incidental control refresh. - _abDisarm(); - _guideTimerSync(); + _abDisarm(); // now syncs the guide scheduler itself _updateLoopRegionControls(); // Abandon any in-progress drag — the global mouse handlers act on // S.drag regardless of mode, so a stale drag would otherwise keep diff --git a/tests/loop_ab.test.js b/tests/loop_ab.test.js index f97cefee..968974f1 100644 --- a/tests/loop_ab.test.js +++ b/tests/loop_ab.test.js @@ -100,7 +100,7 @@ function extractBlock(name) { // its stable endpoints. const abRuntime = (() => { const mm = src.match( - /let _abOn = false;[\s\S]*?\n\/\/ window\.editorToggleLoopAB re-attached in main\.js/); + /(?:export )?let _abOn = false;[\s\S]*?\n\/\/ window\.editorToggleLoopAB re-attached in main\.js/); if (!mm) { console.error('FAIL: A/B runtime slice not found'); process.exit(1); } return mm[0].replace(/^export\s+/gm, ''); })(); diff --git a/tests/metronome_click.test.js b/tests/metronome_click.test.js index adc3a6c3..92069909 100644 --- a/tests/metronome_click.test.js +++ b/tests/metronome_click.test.js @@ -17,7 +17,7 @@ const assert = require('assert'); const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'audio.js'), 'utf8'); const _m0 = src.match(/\/\* @pure:guide-clap:start \*\/[\s\S]*?\/\* @pure:guide-clap:end \*\//); if (!_m0) { - console.error('FAIL: @pure:guide-clap block not found in src/main.js'); + console.error('FAIL: @pure:guide-clap block not found in src/audio.js'); process.exit(1); } const m = [_m0[0].replace(/^export\s+/gm, '')]; diff --git a/tests/onset_snap.test.js b/tests/onset_snap.test.js index 09334bdb..aa89da0a 100644 --- a/tests/onset_snap.test.js +++ b/tests/onset_snap.test.js @@ -25,7 +25,7 @@ const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'u const _m0 = src.match(/\/\* @pure:onset-snap:start \*\/[\s\S]*?\/\* @pure:onset-snap:end \*\//); if (!_m0) { - console.error('FAIL: @pure:onset-snap block not found in src/main.js'); + console.error('FAIL: @pure:onset-snap block not found in src/audio.js'); process.exit(1); } const m = [_m0[0].replace(/^export\s+/gm, '')]; diff --git a/tests/onset_strip.test.js b/tests/onset_strip.test.js index f5b6669e..2cf3a4b3 100644 --- a/tests/onset_strip.test.js +++ b/tests/onset_strip.test.js @@ -18,7 +18,7 @@ const assert = require('assert'); const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'audio.js'), 'utf8'); const _m0 = src.match(/\/\* @pure:onset-strip:start \*\/[\s\S]*?\/\* @pure:onset-strip:end \*\//); if (!_m0) { - console.error('FAIL: @pure:onset-strip block not found in src/main.js'); + console.error('FAIL: @pure:onset-strip block not found in src/audio.js'); process.exit(1); } const m = [_m0[0].replace(/^export\s+/gm, '')];