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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1,128 changes: 1,128 additions & 0 deletions src/audio.js

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions src/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {},
Expand Down
1,105 changes: 29 additions & 1,076 deletions src/main.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions tests/audio_mixer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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');
Expand Down
13 changes: 7 additions & 6 deletions tests/boot_teardown.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
5 changes: 4 additions & 1 deletion tests/compose_transport.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 \\*/'));
Expand Down
7 changes: 4 additions & 3 deletions tests/follow_toggle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };'
)();
Expand Down
7 changes: 4 additions & 3 deletions tests/guide_clap.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions tests/keyboard_gutter.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
32 changes: 23 additions & 9 deletions tests/loop_ab.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };'
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions tests/metronome_click.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };'
Expand Down
21 changes: 12 additions & 9 deletions tests/onset_snap.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,30 @@ 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 };'
)();

// 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}`);
}
Expand Down
9 changes: 5 additions & 4 deletions tests/onset_strip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '')];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const { _onsetTimesFromPeaksPure } = new Function(
'"use strict";' + m[0] + '\nreturn { _onsetTimesFromPeaksPure };'
)();
Expand Down
2 changes: 1 addition & 1 deletion tests/waveform_peaks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;')();
Expand Down
Loading