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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
lint — a yellow underline + the count chip + the popover, never blocking. New
pures `_keysLintPure` / thresholds in `src/playability-lint.js`, covered by
`tests/keys_lint.test.mjs`.
- **Audio regions — a stem's audio can now be placed and trimmed as movable
blocks (Track Regions PR4).** Audio playback becomes one source *per region*
instead of one per stem: a region plays only its media window `[srcIn, srcOut)`
at its placed start (the group shift plus the region's beat-0-relative
position), so a moved or trimmed audio block rides the tempo map for placement
while its content plays at natural speed, never stretched. Trim edges are
declicked with a ~5 ms per-region fade, and each region's waveform thumbnail is
windowed to its own slice. `TrimRegionCmd` makes the trim an undoable,
window-only edit that survives save→reload with no drift. A project with **no
authored regions** has a single full-span region and is byte-identical to
before — playback, scheduling, and the waveform are unchanged. Phase-1 scope
(per `docs`/`TRACK-REGIONS-DESIGN.md`): the active/reference source and the
audition-slow single-stream path stay whole-track; audio-region delete and
split/join are later tiers. New pure cores: `_regionStartPure`,
`_audioRegionPlacementsPure`, `_declickEnvelopePure` (`src/audio.js`),
`TrimRegionCmd` (`src/region-commands.js`), `_regionWaveWindowPure`
(`src/parts-view.js`).

- **The Legacy (EOF) profile is now a faithful port of EOF's pro-guitar keyset,
pinned to the reference.** F1 opens help, Ctrl+1-9 / Ctrl+0 / Ctrl+` set frets
Expand Down
256 changes: 227 additions & 29 deletions src/audio.js

Large diffs are not rendered by default.

58 changes: 41 additions & 17 deletions src/parts-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,30 +108,54 @@ function _isDrumRow(arrIdx, targetId) {
return (arrIdx >= 0 && isDrumArrangement(S.arrangements[arrIdx])) || targetId === 'drums';
}

// An audio lane's waveform, when the host has one cached for the source
// (today: the master mix via S.waveformPeaks; stems light up with the
// engine slice). host.trackWaveform's inert default returns null — the
// lane then just shows its background and downbeats.
// The chart-time span + media window a region's waveform slice occupies, for the
// windowed thumbnail. startTime = the group shift + the region's beat placement
// MEASURED FROM BEAT 0 (so the default region at beat 0 sits at exactly `shift`);
// [srcIn, srcOut) is the media window in the buffer's own seconds (srcOut → the
// buffer end when absent/degenerate). The default full-span region reproduces the
// whole buffer at the shift, so the pre-region single pass is drawn identically.
export function _regionWaveWindowPure(region, shift, duration, beatToTime) {
const dur = Math.max(0, Number(duration) || 0);
const b2t = typeof beatToTime === 'function' ? beatToTime : () => 0;
const srcIn = Math.max(0, Number(region && region.srcIn) || 0);
const so = Number(region && region.srcOut);
const srcOut = Number.isFinite(so) && so > srcIn ? (dur > 0 ? Math.min(so, dur) : so) : dur;
const place = (Number(b2t(Number(region && region.startBeat) || 0)) || 0) - (Number(b2t(0)) || 0);
const startTime = (Number(shift) || 0) + place;
return { startTime, endTime: startTime + (srcOut - srcIn), srcIn, srcOut };
}

// An audio lane's waveform, when the host has one cached for the source (today:
// the master mix via S.waveformPeaks; stems light up with the engine slice).
// host.trackWaveform's inert default returns null — the lane then just shows its
// background and downbeats. Drawn as one WINDOWED pass PER REGION: the default is
// a single full-span region → the whole buffer at `shift`, identical to before;
// a trimmed/moved region shows only its [srcIn,srcOut) slice under its own block.
function _drawTrackAudioWaveform(row, y0, laneH, w) {
const data = host.trackWaveform(row.sourceId);
if (!data || !data.peaks || !data.peaks.bins || !(data.duration > 0)) return;
const pk = data.peaks;
const shift = (Number(S.audioShift) || 0) + (Number(row.sourceOffset) || 0);
const xLo = Math.max(PARTS_GUTTER, Math.floor(timeToX(shift)));
const xHi = Math.min(w, Math.ceil(timeToX(data.duration + shift)));
const mid = y0 + laneH / 2;
const amp = Math.max(2, laneH / 2 - 5);
ctx.strokeStyle = 'rgba(120,150,210,.18)';
ctx.beginPath(); ctx.moveTo(xLo, mid + .5); ctx.lineTo(xHi, mid + .5); ctx.stroke();
ctx.fillStyle = row.sourceKind === 'master' ? 'rgba(95,165,245,.72)' : 'rgba(74,205,220,.72)';
for (let px = xLo; px < xHi; px++) {
let i0 = Math.floor((xToTime(px) - shift) / data.duration * pk.bins);
let i1 = Math.floor((xToTime(px + 1) - shift) / data.duration * pk.bins);
i0 = Math.max(0, Math.min(pk.bins - 1, i0));
i1 = Math.max(i0, Math.min(pk.bins - 1, i1));
let lo = pk.min[i0]; let hi = pk.max[i0];
for (let i = i0 + 1; i <= i1; i++) { if (pk.min[i] < lo) lo = pk.min[i]; if (pk.max[i] > hi) hi = pk.max[i]; }
ctx.fillRect(px, mid - hi * amp, 1, Math.max(1, (hi - lo) * amp));
const fill = row.sourceKind === 'master' ? 'rgba(95,165,245,.72)' : 'rgba(74,205,220,.72)';
for (const region of _trackRegionsResolvePure(row.regions)) {
const win = _regionWaveWindowPure(region, shift, data.duration, (b) => timeOf(S.beats, b));
const xLo = Math.max(PARTS_GUTTER, Math.floor(timeToX(win.startTime)));
const xHi = Math.min(w, Math.ceil(timeToX(win.endTime)));
if (xHi <= xLo) continue;
ctx.strokeStyle = 'rgba(120,150,210,.18)';
ctx.beginPath(); ctx.moveTo(xLo, mid + .5); ctx.lineTo(xHi, mid + .5); ctx.stroke();
ctx.fillStyle = fill;
for (let px = xLo; px < xHi; px++) {
let i0 = Math.floor((win.srcIn + xToTime(px) - win.startTime) / data.duration * pk.bins);
let i1 = Math.floor((win.srcIn + xToTime(px + 1) - win.startTime) / data.duration * pk.bins);
i0 = Math.max(0, Math.min(pk.bins - 1, i0));
i1 = Math.max(i0, Math.min(pk.bins - 1, i1));
let lo = pk.min[i0]; let hi = pk.max[i0];
for (let i = i0 + 1; i <= i1; i++) { if (pk.min[i] < lo) lo = pk.min[i]; if (pk.max[i] > hi) hi = pk.max[i]; }
ctx.fillRect(px, mid - hi * amp, 1, Math.max(1, (hi - lo) * amp));
}
}
}

Expand Down
66 changes: 66 additions & 0 deletions src/region-commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -374,3 +374,69 @@ export class DeleteRegionCmd {
if (this._sel.taken) { S.selectedTrackId = this._sel.track; S.selectedRegionId = this._sel.region; }
}
}

// ════════════════════════════════════════════════════════════════════
// TrimRegionCmd — adjust a region's WINDOW only, never its content
// (track-regions PR4). For an AUDIO region: srcIn/srcOut, the immutable media
// in/out points in the file's OWN seconds (the buffer is never stretched — trim
// is expressed to the scheduler purely as _regionStartPure start()/duration
// args). For a NOTATION region: startBeat/lenBeat, the beat window; notes that
// fall outside the trimmed window are HIDDEN (no longer owned by the region),
// never deleted — a later widen brings them straight back. Container-only: it
// touches nothing but track.regions[], so no editGen/content churn. Rollback
// restores the raw regions[] verbatim (incl. deleting a key that was never
// there). Node-runnable (no DOM). Test: tests/region_trim.test.mjs.
// ════════════════════════════════════════════════════════════════════

// Whitelist the window fields a trim may set, coercing each to a finite number
// or an explicit null (which _trackRegionsNormalizePure reads as "clear this
// bound"). Unknown / NaN / non-numeric fields are dropped so a trim can never
// smuggle content or junk onto a region.
const _TRIM_FIELDS = ['startBeat', 'lenBeat', 'srcIn', 'srcOut'];
export function _trimPatchPure(patch) {
const out = {};
if (!patch || typeof patch !== 'object') return out;
for (const k of _TRIM_FIELDS) {
if (!(k in patch)) continue;
const v = patch[k];
if (v === null) out[k] = null;
else if (typeof v === 'number' && Number.isFinite(v)) out[k] = v;
}
return out;
}

export class TrimRegionCmd {
// `patch` carries the new window fields (startBeat/lenBeat for notation,
// srcIn/srcOut for audio); only whitelisted, finite (or explicit-null)
// values survive, then _trackRegionsNormalizePure clamps/sorts the result.
constructor({ trackId, regionId, patch } = {}) {
this.trackId = trackId;
this.regionId = regionId;
this.patch = _trimPatchPure(patch);
// Window-only: changes WHAT/WHEN a region covers, never a note's pitch,
// so it passes the read-only-roll edit lock like the move/place verbs.
this.pitchPreserving = true;
// The window lives on the track container (song-level), not an arrangement.
this.songScope = true;
this._regionBefore = { taken: false, hadKey: false, value: undefined };
}

exec() {
const track = _findTrack(this.trackId);
if (!track) return;
if (!Object.keys(this.patch).length) return; // no valid fields → true no-op
const resolved = _trackRegionsResolvePure(track.regions);
// Unknown region id → no-op; never materialize the implicit default just
// to write nothing (keeps untouched packs byte-identical).
if (!resolved.some(r => r.id === this.regionId)) return;
this._regionBefore = _snapRegions(track);
const trimmed = resolved.map(r => (
r.id === this.regionId ? { ...r, ...this.patch } : r
));
track.regions = _trackRegionsNormalizePure(trimmed);
}

rollback() {
_restoreRegions(_findTrack(this.trackId), this._regionBefore);
}
}
98 changes: 97 additions & 1 deletion tests/audio_shift.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import assert from 'node:assert';
import { S } from '../src/state.js';
import { EditHistory } from '../src/history.js';
import { _audioBufferStartPure, _audioTimelineDurationPure, AudioShiftCmd, editorSetAudioShift } from '../src/audio.js';
import { _audioBufferStartPure, _audioTimelineDurationPure, _regionStartPure, _audioRegionPlacementsPure, _declickEnvelopePure, AudioShiftCmd, editorSetAudioShift } from '../src/audio.js';
import { seedState, trackHooks, lastStatus } from './_history_env.mjs';

let pass = 0, fail = 0;
Expand Down Expand Up @@ -88,5 +88,101 @@ t('editorSetAudioShift is a no-op when the value is unchanged', () => {
assert.strictEqual(S.history.undo.length, 0, 'no command pushed for a no-op');
});

// ── _regionStartPure — the per-region generalization (track-regions PR4) ──────
// The whole-stem case is a region [srcIn=0, srcOut=duration] placed at the audio
// shift. This MUST stay byte-identical to _audioBufferStartPure so PR4 doesn't
// regress today's single-source scheduling — the pinned invariant.
t('_regionStartPure(cursor, shift, 0, dur) === _audioBufferStartPure for every cursor', () => {
const dur = 30, shift = 4; // +4s pre-roll
for (const cursor of [-2, 0, 3.9, 4, 4.0001, 10, 33.999, 34, 40]) {
const legacy = _audioBufferStartPure(cursor, shift, dur);
const region = _regionStartPure(cursor, shift, 0, dur);
assert.strictEqual(region.play, legacy.play, `play @${cursor}`);
assert.ok(near(region.offset, legacy.offset), `offset @${cursor}: ${region.offset} vs ${legacy.offset}`);
assert.ok(near(region.delay, legacy.delay), `delay @${cursor}: ${region.delay} vs ${legacy.delay}`);
}
});
t('_regionStartPure — a trimmed region plays only its [srcIn,srcOut) window', () => {
// Region: media 2s..6s (4s of content) placed at chart-time 10.
const at = (c) => _regionStartPure(c, 10, 2, 6);
// Before it begins: wait, then start at the in-point, for the full window.
assert.deepStrictEqual(at(8), { play: true, offset: 2, delay: 2, duration: 4 });
// At the start: offset = in-point, no delay.
assert.deepStrictEqual(at(10), { play: true, offset: 2, delay: 0, duration: 4 });
// 2s in: read 2s past the in-point, 2s of window left.
assert.deepStrictEqual(at(12), { play: true, offset: 4, delay: 0, duration: 2 });
// At the trimmed tail (10 + 4): past the region — no source.
assert.deepStrictEqual(at(14), { play: false, offset: 0, delay: 0, duration: 0 });
// Just inside the tail: a sliver still plays.
const sliver = at(13.99);
assert.ok(sliver.play && near(sliver.offset, 5.99) && near(sliver.duration, 0.01), 'sliver at the tail');
});
t('_regionStartPure — no/invalid srcOut means play to the buffer end (duration null)', () => {
// srcOut null or ≤ srcIn → untrimmed tail: duration null (scheduler omits the
// start() 3rd arg) and no past-end cutoff.
assert.deepStrictEqual(_regionStartPure(5, 0, 0, null), { play: true, offset: 5, delay: 0, duration: null });
assert.deepStrictEqual(_regionStartPure(5, 0, 3, 3), { play: true, offset: 8, delay: 0, duration: null });
// Negative srcIn is clamped to 0.
assert.strictEqual(_regionStartPure(5, 0, -1, 20).offset, 5);
});

// ── _audioRegionPlacementsPure — a track's regions → scheduler placements ──────
// This feeds the per-region rewrite of _startStemSources (regions PR4, step 2);
// the default (absent regions) MUST reduce to today's single whole-stem source.
t('_audioRegionPlacementsPure: absent regions → one full-span placement at beat 0', () => {
const p = _audioRegionPlacementsPure(null, 30, (b) => b * 0.5);
assert.strictEqual(p.length, 1);
assert.deepStrictEqual(p[0], { id: 'region:1', startBeatTime: 0, srcIn: 0, srcOut: 30, muted: false });
});
t('_audioRegionPlacementsPure ∘ _regionStartPure (default) === _audioBufferStartPure', () => {
// The whole default chain reproduces the legacy scheduler for every cursor —
// the guarantee that a region-free project schedules exactly as it does now.
const dur = 30; const shift = 4;
const [pl] = _audioRegionPlacementsPure(null, dur, (b) => b * 0.5); // startBeatTime 0
for (const cursor of [-2, 0, 4, 10, 34, 40]) {
const legacy = _audioBufferStartPure(cursor, shift, dur);
const region = _regionStartPure(cursor, shift + pl.startBeatTime, pl.srcIn, pl.srcOut);
assert.strictEqual(region.play, legacy.play, `play @${cursor}`);
assert.ok(near(region.offset, legacy.offset), `offset @${cursor}`);
assert.ok(near(region.delay, legacy.delay), `delay @${cursor}`);
}
});
t('_audioRegionPlacementsPure maps startBeat via beatToTime, resolves + clamps the window', () => {
const regions = [
{ id: 'r2', startBeat: 8, srcIn: 2, srcOut: 6 }, // trimmed, placed at beat 8
{ id: 'r3', startBeat: 0, lenBeat: null }, // full-span (no trim)
{ id: 'r4', startBeat: 4, srcIn: 1, srcOut: 999, muted: true }, // srcOut past end + muted
];
const p = _audioRegionPlacementsPure(regions, 30, (b) => b * 0.5); // 0.5 s/beat
assert.deepStrictEqual(p.map(x => x.id), ['r3', 'r4', 'r2'], 'normalized + sorted by startBeat');
assert.deepStrictEqual(p.find(x => x.id === 'r2'),
{ id: 'r2', startBeatTime: 4, srcIn: 2, srcOut: 6, muted: false }, 'beat 8 → 4s; window [2,6)');
assert.deepStrictEqual(p.find(x => x.id === 'r3'),
{ id: 'r3', startBeatTime: 0, srcIn: 0, srcOut: 30, muted: false }, 'no trim → whole buffer [0,dur)');
const r4 = p.find(x => x.id === 'r4');
assert.strictEqual(r4.srcOut, 30, 'srcOut past the buffer clamps to its duration');
assert.strictEqual(r4.muted, true, 'muted flag carried for the scheduler to skip');
});

// ── _declickEnvelopePure — per-region edge fades (regions PR4, step 5) ─────────
const envNear = (env, exp) => {
assert.strictEqual(env.length, exp.length, `env length ${env.length} vs ${exp.length}`);
env.forEach((pt, i) => {
assert.ok(near(pt.t, exp[i].t), `t[${i}] ${pt.t} vs ${exp[i].t}`);
assert.strictEqual(pt.gain, exp[i].gain, `gain[${i}]`);
});
};
t('_declickEnvelopePure: fade in at the first sample, out before the last', () => {
envNear(_declickEnvelopePure(10, 2, 0.005), [
{ t: 10, gain: 0 }, { t: 10.005, gain: 1 }, { t: 11.995, gain: 1 }, { t: 12, gain: 0 }]);
});
t('_declickEnvelopePure: an open window (null duration) gets only the fade-in', () => {
envNear(_declickEnvelopePure(0, null, 0.005), [{ t: 0, gain: 0 }, { t: 0.005, gain: 1 }]);
});
t('_declickEnvelopePure: fades shrink to duration/2 so they never overlap', () => {
envNear(_declickEnvelopePure(0, 0.006, 0.005), [
{ t: 0, gain: 0 }, { t: 0.003, gain: 1 }, { t: 0.003, gain: 1 }, { t: 0.006, gain: 0 }]);
});

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
Loading
Loading