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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **The loop region, bar selection and scroll viewport moved to `src/loop.js`
(R2, step 28).** 528 lines: the A/B loop strip and its drag/nudge/keyboard
handling, bar-range selection, the scroll-bounds math, and `snapTime` — the one
place a raw time becomes a snapped one, which every timeline placement goes
through. `src/main.js` is down to 7,183 — **66%** below where it started.
Three main.js symbols arrive as host hooks (seek, snap step, Loop-in-3D
refresh); the loop-region and scroll functions and `snapTime` are themselves
host hooks now resolving here. Also removes a leftover dead `_guideTimerSync`
import in main.js from the audio step.


- **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,
Expand Down
6 changes: 6 additions & 0 deletions src/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ export const host = {
*/
finalizeActiveDrag: () => {},

// ── Seek and snap, for src/loop.js ────────────────────────────────
/** Move the cursor/transport to a chart time. */
editorSeekToTime: () => {},
/** The current snap step in seconds. */
editorSnapStepSeconds: () => 0,

// ── Rendering and scroll, for src/audio.js ────────────────────────
/** Force an immediate synchronous repaint (draw() is rAF-coalesced). */
drawNow: () => {},
Expand Down
557 changes: 557 additions & 0 deletions src/loop.js

Large diffs are not rendered by default.

572 changes: 20 additions & 552 deletions src/main.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions tests/group_move_snap.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ 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', 'loop.js'), 'utf8');
const m = src.match(/\/\* @pure:group-time-delta:start[\s\S]*?@pure:group-time-delta:end \*\//);
if (!m) { console.error('FAIL: @pure:group-time-delta block not found'); process.exit(1); }
const { _groupTimeDeltaPure } = new Function('"use strict";' + m[0] + '\nreturn { _groupTimeDeltaPure };')();
const { _groupTimeDeltaPure } = new Function('"use strict";' + m[0].replace(/^export\s+/gm, '') + '\nreturn { _groupTimeDeltaPure };')();

// A 1/2-unit snap grid, and "snap off" (identity).
const snapHalf = t => Math.round(t * 2) / 2;
Expand Down
12 changes: 6 additions & 6 deletions tests/loop_ab.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ const path = require('path');
const assert = require('assert');

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');
// _setLoopRegionEnabled moved to src/loop.js (the loop-region UI); slice it from
// there when a case needs the real disarm path.
const loopSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'loop.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');
console.error('FAIL: @pure:loop-ab block not found in src/audio.js');
process.exit(1);
}
const m = [_m0[0].replace(/^export\s+/gm, '')];
Expand Down Expand Up @@ -154,9 +154,9 @@ function buildAB(opts) {
// drives the true loop-disarm path (for the "restore ref on disable" test).
let loopArm = '';
if (opts.withLoopArm) {
const mm = mainSrc.match(/function _setLoopRegionEnabled\(enabled\) \{[\s\S]*?\n\}/);
const mm = loopSrc.match(/(?:export )?function _setLoopRegionEnabled\(enabled\) \{[\s\S]*?\n\}/);
if (!mm) { console.error('FAIL: _setLoopRegionEnabled not found'); process.exit(1); }
loopArm = '\n' + mm[0];
loopArm = '\n' + mm[0].replace(/^export\s+/gm, '');
}
// The A/B runtime reaches main.js through `host` now; map its two methods to
// the same spies the injected params used to be.
Expand Down
3 changes: 2 additions & 1 deletion tests/loop_beats.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import { setHostHooks } from '../src/host.js';
import { S as realS } from '../src/state.js';
import { TempoGridCmd } from '../src/tempo.js';

const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8');
// The loop-beats helpers moved to src/loop.js; slice them from there.
const src = fs.readFileSync(new URL('../src/loop.js', import.meta.url), 'utf8');

function extractFn(name) {
const start = src.indexOf('function ' + name + '(');
Expand Down
4 changes: 2 additions & 2 deletions tests/loop_nudge.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';

const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8');
const src = fs.readFileSync(new URL('../src/loop.js', import.meta.url), 'utf8');
// The two loop pures moved to src/transport.js (#178). The rest of this file's
// sliced @pure:loop-region block still calls them by name, so prepend their real
// source to the slice — same scope, same behaviour, no re-implementation.
Expand Down Expand Up @@ -40,7 +40,7 @@ function extract(name) {
'/\\* @pure:' + name + ':start \\*/[\\s\\S]*?/\\* @pure:' + name + ':end \\*/');
const m = src.match(re);
if (!m) { console.error(`FAIL: @pure:${name} block missing`); process.exit(1); }
return m[0];
return m[0].replace(/^export\s+/gm, '');
}

const api = new Function(
Expand Down
23 changes: 14 additions & 9 deletions tests/loop_nudge_live.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import assert from 'node:assert';
import fs from 'node:fs';

const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8');
const src = fs.readFileSync(new URL('../src/loop.js', import.meta.url), 'utf8');
// The two loop pures moved to src/transport.js (#178). The rest of this file's
// sliced @pure:loop-region block still calls them by name, so prepend their real
// source to the slice — same scope, same behaviour, no re-implementation.
Expand Down Expand Up @@ -45,12 +45,12 @@ function extract(name) {
'/\\* @pure:' + name + ':start \\*/[\\s\\S]*?/\\* @pure:' + name + ':end \\*/');
const m = src.match(re);
if (!m) { console.error(`FAIL: @pure:${name} block missing`); process.exit(1); }
return m[0];
return m[0].replace(/^export\s+/gm, '');
}
function fn(name) {
const m = src.match(new RegExp('function ' + name + '\\([\\s\\S]*?\\n\\}'));
const m = src.match(new RegExp('(?:export )?function ' + name + '\\([\\s\\S]*?\\n\\}'));
if (!m) { console.error(`FAIL: function ${name} not found`); process.exit(1); }
return m[0];
return m[0].replace(/^export\s+/gm, '');
}

const region = extract('loop-region');
Expand All @@ -66,23 +66,28 @@ const DUR = 10;
function build({ barSel, pref = 'bar', snapEnabled = false, snapIdx = 1,
downbeats = DOWNBEATS, snapStep = 0.5, duration = DUR }) {
const S = { barSel, beats: [], duration, snapEnabled, snapIdx };
// _loopNudgeEdge reaches main.js through `host` now (seek, snap step, the
// Loop-in-3D refresh, draw). Snap step comes from host.editorSnapStepSeconds.
const host = {
editorSnapStepSeconds: () => snapStep,
editorSeekToTime: () => {}, updateLoopIn3DBtn: () => {}, draw: () => {},
};
const factory = new Function(
'S', '_downbeatTimes', '_loopSnapModePref', '_editorSnapStepSeconds',
'S', 'host', '_downbeatTimes', '_loopSnapModePref',
'snapTime', 'SNAP_VALUES', '_editorEffectiveSnapValuePure',
'_updateLoopIn3DBtn', '_renderLoopStrip', 'draw', '_setBarSel',
'_renderLoopStrip', '_setBarSel',
'"use strict";'
+ _loopPuresSrc() + region + '\n' + nudgePure + '\n' + liveModeSrc + '\n' + nudgeSrc
+ '\nreturn _loopNudgeEdge;'
);
const nudge = factory(
S,
S, host,
() => downbeats, // _downbeatTimes
pref, // _loopSnapModePref
() => snapStep, // _editorSnapStepSeconds
(t) => t, // snapTime (identity)
SNAP_VALUES,
(en, v) => (en ? v : 0), // _editorEffectiveSnapValuePure
() => {}, () => {}, () => {},
() => {}, // _renderLoopStrip
(r) => { S.barSel = r; return r; } // _setBarSel: assign (β-sync isn't under test here)
);
return { S, nudge };
Expand Down
11 changes: 6 additions & 5 deletions tests/loop_region.test.mjs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/*
* Loop-region helper tests for src/main.js.
* Loop-region helper tests for src/loop.js.
*
* Run: node tests/loop_region.test.mjs
*/
import assert from 'node:assert';
import fs from 'node:fs';

const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8');
const src = fs.readFileSync(new URL('../src/loop.js', import.meta.url), 'utf8');
// The two loop pures moved to src/transport.js (#178). The rest of this file's
// sliced @pure:loop-region block still calls them by name, so prepend their real
// source to the slice — same scope, same behaviour, no re-implementation.
Expand All @@ -29,11 +29,12 @@ function _loopPuresSrc() {
return out.join('\n') + '\n';
}

const m = src.match(/\/\* @pure:loop-region:start \*\/[\s\S]*?\/\* @pure:loop-region:end \*\//);
if (!m) {
console.error('FAIL: @pure:loop-region block not found in src/main.js');
const _mRaw = src.match(/\/\* @pure:loop-region:start \*\/[\s\S]*?\/\* @pure:loop-region:end \*\//);
if (!_mRaw) {
console.error('FAIL: @pure:loop-region block not found in src/loop.js');
process.exit(1);
}
const m = [_mRaw[0].replace(/^export\s+/gm, '')];

const api = new Function(
'"use strict";' + _loopPuresSrc() + m[0] + '\nreturn { _barSpanForTimesPure, _adjustBarSelEdgePure, _normalizeLoopRegionPure, _loopPlaybackRestartTimePure };'
Expand Down
9 changes: 5 additions & 4 deletions tests/loop_snap_modes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import assert from 'node:assert';
import fs from 'node:fs';

const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8');
const src = fs.readFileSync(new URL('../src/loop.js', import.meta.url), 'utf8');
// The two loop pures moved to src/transport.js (#178). The rest of this file's
// sliced @pure:loop-region block still calls them by name, so prepend their real
// source to the slice — same scope, same behaviour, no re-implementation.
Expand All @@ -42,11 +42,12 @@ function _loopPuresSrc() {
return out.join('\n') + '\n';
}

const m = src.match(/\/\* @pure:loop-region:start \*\/[\s\S]*?\/\* @pure:loop-region:end \*\//);
if (!m) {
console.error('FAIL: @pure:loop-region block not found in src/main.js');
const _mRaw = src.match(/\/\* @pure:loop-region:start \*\/[\s\S]*?\/\* @pure:loop-region:end \*\//);
if (!_mRaw) {
console.error('FAIL: @pure:loop-region block not found in src/loop.js');
process.exit(1);
}
const m = [_mRaw[0].replace(/^export\s+/gm, '')];

const api = new Function(
'"use strict";' + _loopPuresSrc() + m[0]
Expand Down
7 changes: 5 additions & 2 deletions tests/loop_undo_mode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import { setHostHooks } from '../src/host.js';
import { S as realS } from '../src/state.js';
import { TempoMapCmd } from '../src/tempo.js';

const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8');
// The loop helpers moved to src/loop.js; the @pure:pending-view block stayed in
// src/main.js. Slice each from its own source.
const src = fs.readFileSync(new URL('../src/loop.js', import.meta.url), 'utf8');
const mainSrc = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8');

function extractFn(name) {
const start = src.indexOf('function ' + name + '(');
Expand All @@ -43,7 +46,7 @@ function extractFn(name) {
}

// ── The A4 loop helpers are still inline in main.js; TempoMapCmd is imported ──
const pm = src.match(/\/\* @pure:pending-view:start \*\/[\s\S]*?\/\* @pure:pending-view:end \*\//);
const pm = mainSrc.match(/\/\* @pure:pending-view:start \*\/[\s\S]*?\/\* @pure:pending-view:end \*\//);
if (!pm) { console.error('FAIL: @pure:pending-view block not found'); process.exit(1); }

const { _resolvePendingViewStatePure } = new Function(
Expand Down
6 changes: 3 additions & 3 deletions tests/onset_snap.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ const fs = require('fs');
const path = require('path');
const assert = require('assert');

// @pure:onset-snap moved to src/audio.js; snapTime is still in src/main.js.
// @pure:onset-snap moved to src/audio.js; snapTime moved to src/loop.js.
const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'audio.js'), 'utf8');
const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'main.js'), 'utf8');
const mainSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'loop.js'), 'utf8');

const _m0 = src.match(/\/\* @pure:onset-snap:start \*\/[\s\S]*?\/\* @pure:onset-snap:end \*\//);
if (!_m0) {
Expand All @@ -33,7 +33,7 @@ const { _nearestOnsetTimePure } = new Function(
'"use strict";' + m[0] + '\nreturn { _nearestOnsetTimePure };'
)();

// Extract snapTime by name (brace matching — the tempo_beat_drag harness) and
// Extract snapTime (moved to src/loop.js) by name (brace matching) and
// inject its free identifiers so we can drive the onset-vs-grid routing.
function extractFn(name) {
const start = mainSrc.indexOf('function ' + name);
Expand Down
Loading