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..67c36e4c
--- /dev/null
+++ b/src/audio.js
@@ -0,0 +1,1128 @@
+// ════════════════════════════════════════════════════════════════════
+// 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();
+ // 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() {
+ 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..c21c0578 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,10 +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();
- _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
@@ -6641,6 +5593,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..968974f1 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;/);
+ /(?: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];
+ 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..92069909 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) {
- console.error('FAIL: @pure:guide-clap block not found in src/main.js');
+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/audio.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..aa89da0a 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) {
- console.error('FAIL: @pure:onset-snap block not found in src/main.js');
+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/audio.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..2cf3a4b3 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) {
- console.error('FAIL: @pure:onset-strip block not found in src/main.js');
+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/audio.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;')();