diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff4ea5..adfea4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/audio.js b/src/audio.js index 28963b0..bbf1cf0 100644 --- a/src/audio.js +++ b/src/audio.js @@ -20,6 +20,7 @@ // Browser surface: WebAudio (AudioContext), `canvas` (for waveform width), // ════════════════════════════════════════════════════════════════════ import { timeOf } from './beats.js'; +import { _trackRegionsResolvePure } from './region.js'; import { DPR, canvas } from './canvas.js'; import { LABEL_W, timeToX } from './geometry.js'; import { @@ -492,6 +493,86 @@ export function _audioBufferStartPure(cursorTime, audioShift, bufferDuration) { return { play: true, offset: bufOff, delay: 0 }; } +// Per-REGION generalization of _audioBufferStartPure (track-regions PR4). An +// audio region plays media seconds [srcIn, srcOut) starting at chart-time +// `startTime` (= the audio group's shift + timeOf(region.startBeat)); the media +// buffer is never stretched, so trim is expressed purely as start()/duration +// args. Returns { play, offset, delay, duration }: +// • offset — buffer-time to begin at (BufferSource start()'s 2nd arg) +// • delay — seconds to wait before the region begins (cursor is before it) +// • duration — seconds to play from `offset` (start()'s 3rd arg); null = to +// the natural buffer end (an untrimmed region whose out-point is +// the whole file — kept null so the scheduler omits the arg and +// behaves byte-identically to today's whole-stem path). +// The whole-stem case IS a region: _regionStartPure(cursor, audioShift, 0, dur) +// returns the same {play, offset, delay} as _audioBufferStartPure(cursor, +// audioShift, dur) for every cursor — the pinned invariant PR4 rests on. +export function _regionStartPure(cursorTime, startTime, srcIn, srcOut) { + const rel = (Number(cursorTime) || 0) - (Number(startTime) || 0); + const inSec = Math.max(0, Number(srcIn) || 0); + const outNum = Number(srcOut); + const out = Number.isFinite(outNum) && outNum > inSec ? outNum : null; + const contentLen = out != null ? out - inSec : null; // null = to buffer end + // Past the region's trimmed tail → don't play (only the transport runs). + if (contentLen != null && rel >= contentLen) return { play: false, offset: 0, delay: 0, duration: 0 }; + // Cursor is before the region begins → wait `-rel`, then play from the in-point. + if (rel < 0) return { play: true, offset: inSec, delay: -rel, duration: contentLen }; + // Inside the region → play from in + elapsed, for the remaining trimmed length. + return { play: true, offset: inSec + rel, delay: 0, duration: contentLen != null ? contentLen - rel : null }; +} + +// Resolve a track's regions[] into concrete AUDIO placements for the scheduler: +// each region's chart-time start-offset (timeOf(startBeat) — the caller adds the +// audio group's shift) and its media window [srcIn, srcOut) in the buffer's own +// seconds. The implicit default (absent regions) yields ONE full-span placement +// [0, bufferDuration) at beat 0 — today's whole-stem path, so a project with no +// authored regions schedules exactly as it does now. srcOut resolves to the +// buffer end when absent/degenerate; startBeat maps through the injected +// beatToTime so a moved region rides the tempo map. Pure (beatToTime injected). +export function _audioRegionPlacementsPure(regions, bufferDuration, beatToTime) { + const dur = Math.max(0, Number(bufferDuration) || 0); + const b2t = typeof beatToTime === 'function' ? beatToTime : () => 0; + // Measure placement RELATIVE to beat 0 so the default region (startBeat 0) + // contributes zero offset whatever the grid origin — the whole-stem path + // stays at exactly `shift`, byte-identical to before regions. + const zero = Number(b2t(0)) || 0; + const out = []; + for (const r of _trackRegionsResolvePure(regions)) { + const startBeatTime = (Number(b2t(Number(r.startBeat) || 0)) || 0) - zero; + const srcIn = Math.max(0, Number(r.srcIn) || 0); + const so = Number(r.srcOut); + const srcOut = Number.isFinite(so) && so > srcIn ? (dur > 0 ? Math.min(so, dur) : so) : dur; + out.push({ id: r.id, startBeatTime, srcIn, srcOut, muted: r.muted === true }); + } + return out; +} + +// Fade length (seconds) for the per-region declick — long enough to kill an edge +// pop, short enough to be inaudible as a fade (~5 ms; design: sound-design's call). +const DECLICK_FADE = 0.005; + +// Declick envelope for a TRIMMED region's edges: a per-region gain fades from +// silence over `fadeSec` at the region's first sample and back to silence over +// `fadeSec` at its last, so a cut that doesn't land on a zero crossing doesn't +// pop. Returns [{t, gain}] ramp points in ctx time — setValueAtTime the first, +// linearRampToValueAtTime the rest. A region with no known end (open window) gets +// only the fade-in; fades shrink to duration/2 so they never overlap on a short +// region. A FULL-SPAN region never reaches here (its edges are the buffer's own +// silent boundaries), so the untrimmed path stays gain-node-free and unchanged. +export function _declickEnvelopePure(startTime, duration, fadeSec) { + const start = Number(startTime) || 0; + const fade = Math.max(0, Number(fadeSec) || 0); + const dur = (duration != null && Number.isFinite(Number(duration))) ? Math.max(0, Number(duration)) : null; + const inFade = dur != null ? Math.min(fade, dur / 2) : fade; + const env = [{ t: start, gain: 0 }, { t: start + inFade, gain: 1 }]; + if (dur != null) { + const outFade = Math.min(fade, dur / 2); + env.push({ t: start + dur - outFade, gain: 1 }); + env.push({ t: start + dur, gain: 0 }); + } + return env; +} + // Effective timeline length for shifted audio. A positive shift delays the // recording, so its tail ends after the raw buffer duration; negative shifts // crop the front but do not shrink the chart the user already has. @@ -653,16 +734,63 @@ function _auditionRefreshUi() { if (host && typeof host.updateTimeDisplay === 'function') host.updateTimeDisplay(); } +// Preserve the historical S.audioSource surface (stop + assignable onended) +// while the active reference is represented by one BufferSource per region. +// MIDI recording and teardown deliberately treat this as a single source. +function _audioSourceGroup(nodes) { + if (!nodes.length) return null; + let onended = null; + let remaining = nodes.length; + const group = { + stop() { + for (const node of nodes) { + try { node.stop(); } catch (_) { /* already stopped */ } + } + }, + get onended() { return onended; }, + set onended(fn) { onended = typeof fn === 'function' ? fn : null; }, + }; + for (const node of nodes) { + const cleanup = node.onended; + node.onended = (event) => { + if (typeof cleanup === 'function') cleanup.call(node, event); + remaining--; + if (remaining === 0 && onended) onended.call(group, event); + }; + } + return group; +} + export function _startAudioSourceAtCursor(preRoll = 0) { // Slide the recording by S.audioShift (the chart clock, anchored below, is // untouched — only the buffer read position moves). A positive shift can // push the audio start into the future (delay) or, near the end, past the // buffer entirely (no source; the transport still runs so the cursor and // guide advance over the trailing silence). - const st = _audioBufferStartPure(S.cursorTime, - (Number(S.audioShift) || 0) + (Number(S.activeAudioSourceOffset) || 0), - S.audioBuffer && S.audioBuffer.duration); + const duration = S.audioBuffer && S.audioBuffer.duration; + const activeShift = (Number(S.audioShift) || 0) + (Number(S.activeAudioSourceOffset) || 0); + const placements = _audioRegionPlacementsPure( + _trackRegionsForSourceId(S.activeAudioSourceId), duration, + (b) => timeOf(S.beats, b)); + const starts = placements + .filter(region => !region.muted) + .map(region => ({ region, start: _regionStartPure( + S.cursorTime, activeShift + region.startBeatTime, region.srcIn, region.srcOut) })) + .filter(item => item.start.play); + // The pitch-preserving MediaElement can represent only one continuous, + // full-file placement. Authored region windows therefore use the + // sample-accurate 100% path (the same limitation as multi-stem audition). + const wholeFile = placements.length === 1 && !placements[0].muted + && placements[0].startBeatTime === 0 && placements[0].srcIn === 0 + && placements[0].srcOut >= duration; + const st = starts[0] ? starts[0].start : { play: false, offset: 0, delay: 0 }; let slow = _auditionActive(); + if (slow && !wholeFile) { + slow = false; + S.auditionRate = 1; + _auditionRefreshUi(); + setStatus('Audition speed is unavailable for edited audio regions — playing at 100%.'); + } if (slow && st.play) { // Pitch-preserving slow path: the reference rides the MediaElement, so // the sample-accurate BufferSource is silenced. Both feed the SAME @@ -680,21 +808,45 @@ export function _startAudioSourceAtCursor(preRoll = 0) { setStatus('Audition speed is unavailable for this recording — playing at 100%.'); } } - if (!slow && st.play) { + if (!slow && starts.length) { _stopRefMedia(); // rate 1 → the slow-path element must be silent - 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 target = _activeRefTarget(); - if (target) S.audioSource.connect(target); - else S.audioSource.connect(S.audioCtx.destination); _mixApplyFirstPlayFade(); - const when = (preRoll > 0 || st.delay > 0) ? S.audioCtx.currentTime + preRoll + st.delay : 0; - S.audioSource.start(when, st.offset); - } else if (!st.play) { + const ctxNow = S.audioCtx.currentTime; + const nodes = []; + for (const { region, start: p } of starts) { + const node = S.audioCtx.createBufferSource(); + node.buffer = S.audioBuffer; + const when = (preRoll > 0 || p.delay > 0) ? ctxNow + preRoll + p.delay : 0; + const trimmed = region.srcIn > 0 || region.srcOut < duration; + if (trimmed) { + const g = S.audioCtx.createGain(); + const realStart = when > 0 ? when : ctxNow; + const env = _declickEnvelopePure(realStart, p.duration, DECLICK_FADE); + g.gain.setValueAtTime(env[0].gain, env[0].t); + for (let i = 1; i < env.length; i++) { + g.gain.linearRampToValueAtTime(env[i].gain, env[i].t); + } + g.connect(target || S.audioCtx.destination); + node.connect(g); + node.onended = () => { try { g.disconnect(); } catch (_) { /* ctx gone */ } }; + } else if (target) { + node.connect(target); + } else { + node.connect(S.audioCtx.destination); + } + // Keep the untouched one-region case byte-for-byte on the legacy + // two-argument start path. Trimmed windows need the duration cap. + if (trimmed && p.duration != null) node.start(when, p.offset, p.duration); + else node.start(when, p.offset); + nodes.push(node); + } + S.audioSource = _audioSourceGroup(nodes); + } else if (!starts.length) { _stopRefMedia(); S.audioSource = null; } @@ -1836,7 +1988,7 @@ function _partGainsReset() { // silenced — so stems do not sound while slowed (they resume at 100%). // ════════════════════════════════════════════════════════════════════ const stemAudioCache = new Map(); // sourceId → { url, buffer, peaks } -const playingStemSources = new Map(); // sourceId → live AudioBufferSourceNode +const playingStemSources = new Map(); // sourceId → live AudioBufferSourceNode[] (one per region) const stemGainNodes = new Map(); // sourceId → GainNode let stemDecodeGeneration = 0; @@ -1872,6 +2024,16 @@ function _liveAudioSources() { S.trackSession && S.trackSession.removedSourceIds); } +// The regions[] of the audio track that owns a source (matched by sourceId), or +// null → the implicit default full-span region. Lets the scheduler place one +// buffer source per region instead of one per stem. +function _trackRegionsForSourceId(sourceId) { + const tracks = S.trackSession && Array.isArray(S.trackSession.tracks) ? S.trackSession.tracks : null; + if (!tracks) return null; + const track = tracks.find(t => t && t.type === 'audio' && t.sourceId === sourceId); + return track ? track.regions : null; +} + // The sources the multi-source scheduler plays: every live source EXCEPT the // active one, which plays via the S.audioSource reference path (its buffer is // what the waveform shows and onset tools analyze). Pure, for the tests. @@ -1981,7 +2143,9 @@ export function _stemCatchupPure(playStartTime, playStartWall, currentTime, rate export function _pruneStaleStems(liveIds) { for (const id of [...playingStemSources.keys()]) { if (liveIds.has(id)) continue; - try { playingStemSources.get(id).stop(); } catch (_) { /* already stopped */ } + for (const node of playingStemSources.get(id)) { + try { node.stop(); } catch (_) { /* already stopped */ } + } playingStemSources.delete(id); } for (const id of [...stemGainNodes.keys()]) { @@ -2125,36 +2289,70 @@ export function applyStemMix(immediate = false) { } // Schedule every live source EXCEPT the active one (which plays via the -// S.audioSource reference path), sample-aligned: each computes its placement -// from S.audioShift + its own offset and starts at the SAME preRoll-shifted -// anchor. Called from the reference's rate-1 start path (never audition-slow). +// S.audioSource reference path), sample-aligned. Each source's track is placed +// as one buffer source PER REGION: a project with no authored regions has one +// full-span region, so this is byte-identical to the old whole-stem path; a +// moved/trimmed region plays its media window [srcIn,srcOut) starting at the +// group shift + timeOf(startBeat). Called from the reference's rate-1 start path +// (never audition-slow, which is single-stream — see the design's phase-1 note). function _startStemSources(preRoll = 0, cursorTime = S.cursorTime) { _stopStemSources(); if (!S.audioCtx) return 0; let started = 0; + const baseShift = Number(S.audioShift) || 0; + const ctxNow = S.audioCtx.currentTime; for (const source of _liveAudioSources()) { if (source.id === S.activeAudioSourceId) continue; // plays via S.audioSource const cached = stemAudioCache.get(source.id); if (!cached || !cached.buffer) continue; // not decoded yet — syncStemAudio catches up - const placement = _audioBufferStartPure( - cursorTime, (Number(S.audioShift) || 0) + source.offset, cached.buffer.duration); - if (!placement.play) continue; - const node = S.audioCtx.createBufferSource(); - node.buffer = cached.buffer; - node.connect(_ensureStemGain(source.id) || _ensureRefGain() || S.audioCtx.destination); - const when = (preRoll > 0 || placement.delay > 0) - ? S.audioCtx.currentTime + preRoll + placement.delay : 0; - node.start(when, placement.offset); - playingStemSources.set(source.id, node); - started++; + const dest = _ensureStemGain(source.id) || _ensureRefGain() || S.audioCtx.destination; + const nodes = []; + for (const region of _audioRegionPlacementsPure( + _trackRegionsForSourceId(source.id), cached.buffer.duration, + (b) => timeOf(S.beats, b))) { + if (region.muted) continue; + const startTime = baseShift + source.offset + region.startBeatTime; + const p = _regionStartPure(cursorTime, startTime, region.srcIn, region.srcOut); + if (!p.play) continue; + const node = S.audioCtx.createBufferSource(); + node.buffer = cached.buffer; + const when = (preRoll > 0 || p.delay > 0) ? ctxNow + preRoll + p.delay : 0; + // Declick a TRIMMED region's edges via a per-region gain (fade in/out + // ~5 ms); a full-span region keeps the buffer's own silent boundaries, + // so it routes straight to the stem gain — byte-identical to today. + const trimmed = region.srcIn > 0 || region.srcOut < cached.buffer.duration; + if (trimmed) { + const g = S.audioCtx.createGain(); + const realStart = when > 0 ? when : ctxNow; + const env = _declickEnvelopePure(realStart, p.duration, DECLICK_FADE); + g.gain.setValueAtTime(env[0].gain, env[0].t); + for (let i = 1; i < env.length; i++) g.gain.linearRampToValueAtTime(env[i].gain, env[i].t); + g.connect(dest); + node.connect(g); + // Release the transient gain when the source ends (natural or stop()). + node.onended = () => { try { g.disconnect(); } catch (_) { /* ctx gone */ } }; + } else { + node.connect(dest); + } + // duration caps a trimmed region; for a full-span region it equals + // the remaining buffer, so start() plays to the end exactly as the + // single-source path did (null only for a degenerate open window). + if (p.duration != null) node.start(when, p.offset, p.duration); + else node.start(when, p.offset); + nodes.push(node); + started++; + } + if (nodes.length) playingStemSources.set(source.id, nodes); } applyStemMix(true); return started; } function _stopStemSources() { - for (const node of playingStemSources.values()) { - try { node.stop(); } catch (_) { /* already stopped */ } + for (const nodes of playingStemSources.values()) { + for (const node of nodes) { + try { node.stop(); } catch (_) { /* already stopped */ } + } } playingStemSources.clear(); } diff --git a/src/parts-view.js b/src/parts-view.js index f2c7f9f..da9a8bf 100644 --- a/src/parts-view.js +++ b/src/parts-view.js @@ -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)); + } } } diff --git a/src/region-commands.js b/src/region-commands.js index d76fdea..3803191 100644 --- a/src/region-commands.js +++ b/src/region-commands.js @@ -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); + } +} diff --git a/tests/audio_shift.test.mjs b/tests/audio_shift.test.mjs index a4fa1a3..9368cd6 100644 --- a/tests/audio_shift.test.mjs +++ b/tests/audio_shift.test.mjs @@ -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; @@ -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); diff --git a/tests/audition_clock.test.mjs b/tests/audition_clock.test.mjs index bf82164..c41d1ca 100644 --- a/tests/audition_clock.test.mjs +++ b/tests/audition_clock.test.mjs @@ -206,16 +206,21 @@ t('a failed slow path DEMOTES to 100% and plays the buffer — never silence und audioBuffer: { duration: 60 }, audioSource: null, audioCtx: { currentTime: 0, createBufferSource: () => ({ connect() {}, start() {} }) }, }; - const run = new Function('S', '_audioBufferStartPure', '_auditionActive', '_startRefMediaAt', + const run = new Function('S', '_auditionActive', '_startRefMediaAt', '_mixApplyFirstPlayFade', '_stopRefMedia', '_auditionRefreshUi', 'setStatus', '_ensureRefGain', '_activeRefTarget', '_anchorTransportAtCursor', '_stopStemSources', '_startStemSources', + '_audioRegionPlacementsPure', '_trackRegionsForSourceId', 'timeOf', '_regionStartPure', + '_audioSourceGroup', '_declickEnvelopePure', 'DECLICK_FADE', extractFn('_startAudioSourceAtCursor') + '\nreturn _startAudioSourceAtCursor;' )(S, - () => ({ play: true, offset: 5, delay: 0 }), () => Number(S.auditionRate) < 1, () => false, // the slow path is unavailable () => {}, () => {}, () => {}, (m) => status.push(m), () => null, () => ({ connect() {} }), - () => {}, () => {}, () => 0); // stem scheduler stubs (no stems here) + () => {}, () => {}, () => 0, // stem scheduler stubs (no stems here) + () => [{ startBeatTime: 0, srcIn: 0, srcOut: 60, muted: false }], + () => null, (_beats, beat) => beat, + () => ({ play: true, offset: 5, delay: 0, duration: 55 }), + (nodes) => nodes[0], () => [], 0.005); run(0); assert.strictEqual(S.auditionRate, 1, 'demoted to full speed so the clock matches the audio'); @@ -223,5 +228,54 @@ t('a failed slow path DEMOTES to 100% and plays the buffer — never silence und assert.ok(status.some(m => /unavailable/i.test(m)), 'and the user is told why'); }); +t('the active reference schedules every authored audio region (it is not skipped)', () => { + const starts = []; + const target = { connect() {} }; + const S = { + cursorTime: 5, audioShift: 0, activeAudioSourceOffset: 0, + activeAudioSourceId: 'master', auditionRate: 1, beats: [], + audioBuffer: { duration: 60 }, audioSource: null, + audioCtx: { + currentTime: 10, + destination: target, + createBufferSource: () => ({ + connect() {}, stop() {}, + start(...args) { starts.push(args); }, + }), + createGain: () => ({ + gain: { setValueAtTime() {}, linearRampToValueAtTime() {} }, + connect() {}, disconnect() {}, + }), + }, + }; + const run = new Function('S', '_auditionActive', '_startRefMediaAt', + '_mixApplyFirstPlayFade', '_stopRefMedia', '_auditionRefreshUi', 'setStatus', + '_ensureRefGain', '_activeRefTarget', '_anchorTransportAtCursor', '_stopStemSources', '_startStemSources', + '_audioRegionPlacementsPure', '_trackRegionsForSourceId', 'timeOf', '_regionStartPure', + '_audioSourceGroup', '_declickEnvelopePure', 'DECLICK_FADE', + extractFn('_startAudioSourceAtCursor') + '\nreturn _startAudioSourceAtCursor;' + )(S, + () => false, () => false, + () => {}, () => {}, () => {}, () => {}, () => null, () => target, + () => {}, () => {}, () => 0, + () => [ + { startBeatTime: 0, srcIn: 2, srcOut: 8, muted: false }, + { startBeatTime: 10, srcIn: 1, srcOut: 4, muted: false }, + ], + () => [], (_beats, beat) => beat, + (cursor, start, srcIn, srcOut) => cursor >= start + ? { play: true, offset: srcIn + cursor - start, delay: 0, duration: srcOut - srcIn - (cursor - start) } + : { play: true, offset: srcIn, delay: start - cursor, duration: srcOut - srcIn }, + (nodes) => ({ nodes, stop() {} }), + (when, duration) => [{ t: when, gain: 0 }, { t: when + Math.min(0.005, duration / 2), gain: 1 }], + 0.005); + + run(0); + assert.strictEqual(starts.length, 2, 'both active-source regions get BufferSource nodes'); + assert.deepStrictEqual(starts[0], [0, 7, 1], 'cursor is caught up inside the first trim'); + assert.deepStrictEqual(starts[1], [15, 1, 3], 'the future region is scheduled at its own in-point'); + assert.strictEqual(S.audioSource.nodes.length, 2, 'the stop/onended wrapper owns the whole active group'); +}); + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0); diff --git a/tests/region_trim.test.mjs b/tests/region_trim.test.mjs new file mode 100644 index 0000000..16e12f8 --- /dev/null +++ b/tests/region_trim.test.mjs @@ -0,0 +1,129 @@ +/* + * Region TRIM (track-regions PR4): TrimRegionCmd adjusts a region's WINDOW only + * — never its content — as one undoable edit. + * + * Pinned here: + * - audio trim moves the media in/out points (srcIn/srcOut) in the file's own + * seconds; srcOut:null clears the out-point ("play to the buffer end"). + * - notation trim narrows the beat window (lenBeat/startBeat) WITHOUT touching + * the arrangement's notes — edge notes are hidden by the window, never deleted. + * - round-trips through the real S.history path: exec → undo restores the raw + * regions[] EXACTLY (incl. deleting a `regions` key that was never there), + * redo reproduces the trim. + * - trimming the implicit default region materializes it; an unknown region id + * or an all-dropped patch is a true no-op that never materializes the default. + * - _trimPatchPure whitelists window fields (finite number | explicit null), + * dropping content/junk so a trim can't smuggle anything onto a region. + * + * Fails on main: TrimRegionCmd / _trimPatchPure do not exist there. + * + * Run: node tests/region_trim.test.mjs + */ +import assert from 'node:assert'; + +import { seedState, trackHooks } from './_history_env.mjs'; +import { _regionsAreDefaultPure } from '../src/region.js'; +import { TrimRegionCmd, _trimPatchPure } from '../src/region-commands.js'; +import { EditHistory } from '../src/history.js'; +import { S } from '../src/state.js'; + +let pass = 0; let fail = 0; +const tests = []; +const t = (name, fn) => tests.push([name, fn]); +const clone = (v) => JSON.parse(JSON.stringify(v)); + +// Constant 120 BPM (0.5 s/beat), 4 beats/bar. +function constGrid() { + const b = []; + for (let i = 0; i < 13; i++) b.push({ time: i * 0.5, measure: i % 4 === 0 ? i / 4 + 1 : 0 }); + return b; +} +const note = (time, sustain = 0) => ({ time, sustain, string: 0, fret: 0, techniques: {} }); + +function seed({ trackId = 'audio:master', type = 'audio', regions, notes } = {}) { + const arr = notes ? { name: 'Lead', notes } : null; + const track = { id: trackId, type, ...(regions ? { regions } : {}) }; + seedState({ arrangements: arr ? [arr] : [], currentArr: 0, beats: constGrid(), drumTab: null, + trackSession: { version: 3, tracks: [track], removedSourceIds: [], + tempoGuideSourceId: '', tempoGuideLocked: false, tempoGuideMode: 'audio' }, + selectedTrackId: '', selectedRegionId: '' }); + S.history = new EditHistory(); + trackHooks(); + return { track, arr }; +} + +// ── _trimPatchPure ──────────────────────────────────────────────────── +t('_trimPatchPure keeps finite numbers + explicit null, drops everything else', () => { + assert.deepStrictEqual( + _trimPatchPure({ srcIn: 2, srcOut: null, startBeat: 4, lenBeat: 8 }), + { srcIn: 2, srcOut: null, startBeat: 4, lenBeat: 8 }); + assert.deepStrictEqual(_trimPatchPure({ srcIn: NaN, srcOut: '3', foo: 1, notes: [] }), {}, + 'NaN / string / non-window fields dropped'); + assert.deepStrictEqual(_trimPatchPure(null), {}); +}); + +// ── audio trim ──────────────────────────────────────────────────────── +t('audio trim: srcIn/srcOut move the media window; exec→undo→redo round-trips', () => { + const { track } = seed({ regions: [{ id: 'region:2', startBeat: 0, lenBeat: null, srcIn: 1, srcOut: 6 }] }); + const before = clone(track.regions); + S.history.exec(new TrimRegionCmd({ trackId: 'audio:master', regionId: 'region:2', patch: { srcIn: 2, srcOut: 5 } })); + assert.deepStrictEqual(track.regions, [{ id: 'region:2', startBeat: 0, lenBeat: null, srcIn: 2, srcOut: 5 }], + 'window trimmed to the new in/out'); + const after = clone(track.regions); + S.history.doUndo(); + assert.deepStrictEqual(track.regions, before, 'undo restores the pre-trim window EXACTLY'); + S.history.doRedo(); + assert.deepStrictEqual(track.regions, after, 'redo reproduces the trim'); +}); + +t('audio trim: srcOut:null clears the out-point but keeps the in-point', () => { + const { track } = seed({ regions: [{ id: 'region:2', startBeat: 0, lenBeat: null, srcIn: 1, srcOut: 6 }] }); + new TrimRegionCmd({ trackId: 'audio:master', regionId: 'region:2', patch: { srcOut: null } }).exec(); + assert.strictEqual(track.regions[0].srcIn, 1, 'in-point kept'); + assert.ok(track.regions[0].srcOut == null, 'out-point cleared → play to the buffer end'); +}); + +t('trim preserves an authored region name (routes through normalize)', () => { + const { track } = seed({ regions: [{ id: 'region:2', startBeat: 0, lenBeat: null, srcIn: 0, srcOut: 6, name: 'Chorus' }] }); + new TrimRegionCmd({ trackId: 'audio:master', regionId: 'region:2', patch: { srcIn: 1 } }).exec(); + assert.strictEqual(track.regions[0].name, 'Chorus', 'the label survives a trim'); +}); + +// ── notation trim ───────────────────────────────────────────────────── +t('notation trim: lenBeat narrows the window but never touches the notes', () => { + const { track, arr } = seed({ trackId: 'transcription:Lead', type: 'transcription', + notes: [note(0.5), note(1.0), note(1.5), note(2.0)], + regions: [{ id: 'region:2', startBeat: 0, lenBeat: 8 }] }); + const notesBefore = clone(arr.notes); + new TrimRegionCmd({ trackId: 'transcription:Lead', regionId: 'region:2', patch: { lenBeat: 4 } }).exec(); + assert.strictEqual(track.regions[0].lenBeat, 4, 'window narrowed to 4 beats'); + assert.deepStrictEqual(arr.notes, notesBefore, 'notes are HIDDEN by the window, never deleted'); +}); + +// ── default-region + no-op edges ────────────────────────────────────── +t('trimming the implicit default materializes it; undo deletes the key', () => { + const { track } = seed({}); // no regions key → implicit default region:1 + assert.ok(!('regions' in track), 'starts default (no key)'); + const cmd = new TrimRegionCmd({ trackId: 'audio:master', regionId: 'region:1', patch: { srcIn: 2, srcOut: 5 } }); + cmd.exec(); + assert.ok(Array.isArray(track.regions) && track.regions[0].srcIn === 2, 'default materialized with the trim'); + assert.strictEqual(_regionsAreDefaultPure(track.regions), false, 'no longer the implicit default'); + cmd.rollback(); + assert.ok(!('regions' in track), 'undo removes the regions key that was never there'); +}); + +t('unknown region id and all-dropped patch are both no-ops (default never materialized)', () => { + const { track } = seed({}); + new TrimRegionCmd({ trackId: 'audio:master', regionId: 'region:999', patch: { srcIn: 2 } }).exec(); + assert.ok(!('regions' in track), 'unknown id never materializes the default'); + new TrimRegionCmd({ trackId: 'audio:master', regionId: 'region:1', patch: { foo: 1 } }).exec(); + assert.ok(!('regions' in track), 'empty (all-dropped) patch is a true no-op'); +}); + +for (const [name, fn] of tests) { + try { await fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/tests/region_waveform.test.mjs b/tests/region_waveform.test.mjs new file mode 100644 index 0000000..a008f55 --- /dev/null +++ b/tests/region_waveform.test.mjs @@ -0,0 +1,56 @@ +/* + * Windowed audio-region waveform (track-regions PR4, step 5): _regionWaveWindowPure + * maps a region to the chart-time span + media window [srcIn,srcOut) its waveform + * thumbnail draws — so a trimmed/moved audio region shows only its own slice. + * + * Pinned here: + * - the default full-span region resolves to the WHOLE buffer at `shift`, + * independent of the grid origin (placement is measured from beat 0), so the + * pre-region single-pass draw is reproduced byte-for-byte; + * - a trimmed + moved region maps to [srcIn,srcOut) starting at its placed time; + * - an absent srcOut opens to the buffer end; a srcOut past the buffer clamps. + * + * Fails on main: _regionWaveWindowPure does not exist there. + * + * Run: node tests/region_waveform.test.mjs + */ +import assert from 'node:assert'; + +const { _regionWaveWindowPure } = await import('../src/parts-view.js'); + +let pass = 0; let fail = 0; +const tests = []; +const t = (name, fn) => tests.push([name, fn]); +const near = (a, b, eps = 1e-9) => Math.abs(a - b) < eps; +const winNear = (win, exp) => { + for (const k of ['startTime', 'endTime', 'srcIn', 'srcOut']) { + assert.ok(near(win[k], exp[k]), `${k}: ${win[k]} vs ${exp[k]}`); + } +}; + +t('default region → whole buffer at the shift, independent of the grid origin', () => { + // beatToTime with a NONZERO value at beat 0 (origin offset 7): the default + // region must still sit at exactly `shift` because placement is beat-0-relative. + const b2t = (b) => b * 0.5 + 7; + winNear(_regionWaveWindowPure({ id: 'region:1', startBeat: 0, lenBeat: null }, 4, 30, b2t), + { startTime: 4, endTime: 34, srcIn: 0, srcOut: 30 }); +}); +t('a trimmed + moved region maps to its [srcIn,srcOut) slice at its placed start', () => { + const b2t = (b) => b * 0.5; + winNear(_regionWaveWindowPure({ id: 'r2', startBeat: 8, srcIn: 2, srcOut: 6 }, 1, 30, b2t), + { startTime: 5, endTime: 9, srcIn: 2, srcOut: 6 }); // 1 + (4-0) = 5; a 4s window +}); +t('srcOut absent → buffer end; srcOut past the buffer → clamped', () => { + const b2t = (b) => b * 0.5; + winNear(_regionWaveWindowPure({ id: 'r3', startBeat: 0, srcIn: 3 }, 0, 30, b2t), + { startTime: 0, endTime: 27, srcIn: 3, srcOut: 30 }); // open → dur + winNear(_regionWaveWindowPure({ id: 'r4', startBeat: 0, srcIn: 1, srcOut: 999 }, 0, 30, b2t), + { startTime: 0, endTime: 29, srcIn: 1, srcOut: 30 }); // clamp to dur +}); + +for (const [name, fn] of tests) { + try { await fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +} +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); diff --git a/tests/test_track_regions.py b/tests/test_track_regions.py index 0a251e2..4098b31 100644 --- a/tests/test_track_regions.py +++ b/tests/test_track_regions.py @@ -80,3 +80,19 @@ def test_authored_regions_round_trip_through_manifest_yaml(): again = _coerce_track_session(loaded) assert again["tracks"][0]["regions"] == [ {"id": "r1", "startBeat": 8.0, "lenBeat": 8.0, "name": "Chorus"}] + + +def test_audio_trimmed_region_round_trips_through_manifest_yaml(): + # The round-trip above covers a NOTATION window (startBeat/lenBeat); this pins + # the AUDIO trim pair (fractional srcIn/srcOut) through the same coerce -> + # YAML dump/load -> coerce path with no drift (PR4 "identical window"). + session = _coerce_track_session({ + "tracks": [{"id": "audio:master", "type": "audio", "sourceId": "master", + "regions": [{"id": "r1", "startBeat": 8, "lenBeat": None, + "srcIn": 1.25, "srcOut": 3.75, "name": "Solo"}]}], + }) + loaded = yaml.safe_load(yaml.safe_dump(session, sort_keys=False, allow_unicode=True)) + again = _coerce_track_session(loaded) + assert again["tracks"][0]["regions"] == [ + {"id": "r1", "startBeat": 8.0, "lenBeat": None, + "srcIn": 1.25, "srcOut": 3.75, "name": "Solo"}] diff --git a/tests/track_regions.test.mjs b/tests/track_regions.test.mjs index e8a481d..22c6f22 100644 --- a/tests/track_regions.test.mjs +++ b/tests/track_regions.test.mjs @@ -176,6 +176,24 @@ t('normalize is idempotent over region data', () => { assert.deepStrictEqual(twice, once); }); +t('a TRIMMED AUDIO region survives the save→reload round-trip with no drift (PR4)', () => { + // TrimRegionCmd writes fractional srcIn/srcOut onto an audio track's region. + // Save (normalize) → JSON wire → reload (normalize) must yield an IDENTICAL + // window — the PR4 "no start/len drift" contract, for AUDIO trim specifically + // (the model round-trips above only cover a notation window). + const input = { ...empty, tracks: [ + { id: 'audio:master', type: 'audio', sourceId: 'master', + regions: [{ id: 'r1', startBeat: 8, lenBeat: null, srcIn: 1.25, srcOut: 3.75, name: 'Solo' }] }, + ] }; + const saved = _trackSessionNormalizePure(input, sources, arrangements, drumTab); + const savedRegions = saved.tracks.find(tk => tk.id === 'audio:master').regions; + assert.deepStrictEqual(savedRegions, + [{ id: 'r1', startBeat: 8, lenBeat: null, srcIn: 1.25, srcOut: 3.75, name: 'Solo' }], + 'the trimmed window is carried verbatim on save'); + const reloaded = _trackSessionNormalizePure(JSON.parse(JSON.stringify(saved)), sources, arrangements, drumTab); + assert.deepStrictEqual(reloaded, saved, 'reload yields an identical session — no start/len/trim drift'); +}); + for (const [name, fn] of tests) { try { await fn(); pass++; console.log(' ok ' + name); } catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); }