diff --git a/src/utils/waveform/autodjEdgeMix.test.ts b/src/utils/waveform/autodjEdgeMix.test.ts index 169fb407..41c6999a 100644 --- a/src/utils/waveform/autodjEdgeMix.test.ts +++ b/src/utils/waveform/autodjEdgeMix.test.ts @@ -161,6 +161,26 @@ describe('analyzeEdge — duration & shape (§3)', () => { expect(analyzeEdge(constBins(0), DUR, 'end')!.kind).toBe('silent'); }); + it('does not count trim-silence residue after capped lead trim in start edge span', () => { + // 30 silent bins (~14 s raw) but playback trim cap → seek at bin ~10; bins 10–29 + // are still digital silence and must not inflate the edge / fade window. + const bins = new Array(N).fill(pcmBinForT(1)); + for (let i = 0; i < 30; i++) bins[i] = 8; + const edge = analyzeEdge(bins, DUR, 'start'); + expect(edge!.kind).toBe('hard'); + expect(edge!.shape.y0).toBeCloseTo(1, 1); + expect(edge!.shape.seconds).toBeGreaterThan(2); + }); + + it('does not count trim-silence residue before capped trail trim in end edge span', () => { + const bins = new Array(N).fill(pcmBinForT(1)); + for (let i = 0; i < 30; i++) bins[N - 1 - i] = 8; + const edge = analyzeEdge(bins, DUR, 'end'); + expect(edge!.kind).toBe('hard'); + expect(edge!.shape.y0).toBeCloseTo(1, 1); + expect(edge!.shape.seconds).toBeGreaterThan(2); + }); + it('silence-only track → raw 0 → min_duration, y0 ≈ 0 (fallback threshold path)', () => { const edge = analyzeEdge(constBins(0), DUR, 'end'); expect(edge).not.toBeNull(); diff --git a/src/utils/waveform/autodjEdgeMix.ts b/src/utils/waveform/autodjEdgeMix.ts index 0e217297..bb0ca5a9 100644 --- a/src/utils/waveform/autodjEdgeMix.ts +++ b/src/utils/waveform/autodjEdgeMix.ts @@ -17,13 +17,15 @@ * task spec §16.1 / §16.7 for the full rationale. */ import { coerceWaveformBins } from './waveformParse'; -import { computeWaveformSilence, peakHalf } from './waveformSilence'; +import { computeWaveformSilence, contentPlayBinRange, peakHalf } from './waveformSilence'; /** Gamma applied by `normalize_peak_bins` before storage (perceptual shaping). */ export const WAVEFORM_GAMMA = 0.52; /** PCM percentile path floors silence at this u8; byte-envelope can emit 0. */ const PCM_FLOOR = 8; +/** Bins at/below this peak value are trim-silence (must match waveformSilence cut). */ +const SILENCE_CUT = 12; const DEFAULT_MIN_DURATION = 0.5; const DEFAULT_MAX_DURATION = 12.0; @@ -152,10 +154,12 @@ function binToAmplitude(bin: number, encoding: WaveformEncoding, gamma: number): return unGammaToAmplitude(normU8(bin, encoding), gamma); } -/** 75th-percentile of above-floor amplitudes — the track's own "plateau" level. */ -function plateauAmplitude(tValues: number[]): number { +/** 75th-percentile of above-floor amplitudes within the playable content window. */ +function plateauAmplitude(tValues: number[], peak: number[], startBin: number, endBin: number): number { const loud: number[] = []; - for (const t of tValues) if (t > PLATEAU_SILENCE_FLOOR_T) loud.push(t); + for (let i = startBin; i < endBin; i++) { + if (peak[i] > SILENCE_CUT && tValues[i] > PLATEAU_SILENCE_FLOOR_T) loud.push(tValues[i]); + } if (loud.length === 0) return 0; loud.sort((a, b) => a - b); return loud[Math.min(loud.length - 1, Math.floor(loud.length * 0.75))]; @@ -216,7 +220,13 @@ export function analyzeEdge( // Plateau-relative threshold (un-gamma'd amplitude domain). Fallback to the // absolute normU8 threshold (also un-gamma'd) when no usable plateau exists. - const plateau = plateauAmplitude(tValues); + // Plateau is measured only inside the playable content window — not from trim + // silence bins that would skew the threshold. + const playRange = contentPlayBinRange(peak, dur); + const playStartBin = playRange?.startBin ?? 0; + const playEndBin = playRange?.endBin ?? n; + + const plateau = plateauAmplitude(tValues, peak, playStartBin, playEndBin); const thresholdT = plateau > 0 ? clamp(plateauFactor * plateau, absFloorT, THRESHOLD_T_CAP) @@ -224,35 +234,32 @@ export function analyzeEdge( const secPerBin = dur / n; - // Anchor the edge at the *content* boundary, not the raw file edge. Leading / - // trailing digital silence is trimmed by the orthogonal silence layer (B is - // even seeked to `bStartSec`), so walking the loud run from bin 0 / bin n-1 - // would stop immediately inside that silence and collapse essentially every - // edge to `min_duration` — a tiny overlap that plays like gapless. Walking - // from the trimmed content edge makes the run reflect the actual music - // approaching the boundary. (§10.2's "quiet intro → min_duration" still holds: - // that is genuinely soft *content*, distinct from trimmed silence.) - const silence = computeWaveformSilence(coerced, dur); - const contentStartBin = clamp(Math.round(silence.contentStartSec / secPerBin), 0, n - 1); - const contentEndBin = clamp(Math.round(silence.contentEndSec / secPerBin), contentStartBin + 1, n); + // Anchor edge walks at the first/last *audible* bin inside the playback window. + // `computeWaveformSilence` may seek past only part of a long digital tail (trim + // cap); bins between the seek point and real music are still trim-silence and + // must not inflate edge span or fade length. + const isTrimSilence = (idx: number) => + idx < playStartBin || idx >= playEndBin || peak[idx] <= SILENCE_CUT; + + let edgeBin: number; + if (side === 'start') { + edgeBin = playStartBin; + while (edgeBin < playEndBin && isTrimSilence(edgeBin)) edgeBin++; + } else { + edgeBin = playEndBin - 1; + while (edgeBin >= playStartBin && isTrimSilence(edgeBin)) edgeBin--; + } + if (edgeBin < playStartBin || edgeBin >= playEndBin) { + return { side, kind: 'silent', shape: { seconds: minDuration, y0: 0, y1: 0 } }; + } - // Step 1 — edge transition span from the content edge → raw duration. The - // boundary bin is either at/above the loud threshold or below it, and the two - // cases call for different spans: - // • Hard edge (boundary loud): measure the contiguous *loud run* — the room - // the engine has to fade A out / fade B in over solid material. - // • Natural fade-out / fade-in (boundary below threshold): measure the - // *envelope run* back to where the signal reaches the loud body, so B - // rises over A's own recorded fade (scenario A) instead of the mix - // collapsing to min_duration and switching only once A is already silent. - // The boundary bin belongs to exactly one case, so there is no double count. - const loudAt = (idx: number) => tValues[idx] >= thresholdT; - const edgeBin = side === 'start' ? contentStartBin : contentEndBin - 1; const step = side === 'start' ? 1 : -1; - const past = (idx: number) => (side === 'start' ? idx >= contentEndBin : idx < contentStartBin); + const past = (idx: number) => + side === 'start' ? idx >= playEndBin || isTrimSilence(idx) : idx < playStartBin || isTrimSilence(idx); let runBins = 0; let kind: EdgeKind; + const loudAt = (idx: number) => tValues[idx] >= thresholdT; if (loudAt(edgeBin)) { let i = edgeBin; while (!past(i) && loudAt(i)) { i += step; runBins++; } @@ -278,8 +285,8 @@ export function analyzeEdge( const windowBins = clamp(Math.round(edgeSeconds / secPerBin), 1, n); const points: { t: number; y: number }[] = []; for (let k = 0; k < windowBins; k++) { - const binIndex = side === 'start' ? contentStartBin + k : contentEndBin - 1 - k; - if (binIndex < 0 || binIndex >= n) break; + const binIndex = side === 'start' ? edgeBin + k : edgeBin - k; + if (binIndex < playStartBin || binIndex >= playEndBin || isTrimSilence(binIndex)) break; points.push({ t: k * secPerBin, y: tValues[binIndex] }); } diff --git a/src/utils/waveform/waveformSilence.test.ts b/src/utils/waveform/waveformSilence.test.ts index 419a9ffe..273c3860 100644 --- a/src/utils/waveform/waveformSilence.test.ts +++ b/src/utils/waveform/waveformSilence.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { analyzeBoundary, computeWaveformSilence, planCrossfadeTransition } from './waveformSilence'; +import { analyzeBoundary, computeWaveformSilence, contentPlayBinRange, planCrossfadeTransition } from './waveformSilence'; /** Build a 500-bin peak curve: `lead` silent bins, a loud middle, `trail` silent bins. */ function curve(lead: number, mid: number, trail: number, loud = 200, quiet = 4): number[] { @@ -61,6 +61,14 @@ describe('computeWaveformSilence', () => { expect(r.trailSilenceSec).toBe(8); }); + it('contentPlayBinRange matches capped trim in bin space', () => { + const bins = curve(20, 470, 10); + const peak = bins; // single curve in tests + const range = contentPlayBinRange(peak, 250)!; + expect(range.startBin).toBe(10); // 5 s cap at 0.5 s/bin + expect(range.endBin).toBe(490); + }); + it('never trims a fully-silent curve to nothing', () => { const bins = Array(500).fill(3); const r = computeWaveformSilence(bins, 120); diff --git a/src/utils/waveform/waveformSilence.ts b/src/utils/waveform/waveformSilence.ts index 51fd8b8d..4a93c01b 100644 --- a/src/utils/waveform/waveformSilence.ts +++ b/src/utils/waveform/waveformSilence.ts @@ -33,6 +33,42 @@ export interface WaveformSilenceOptions { const DEFAULT_SILENCE_CUT = 12; const DEFAULT_MAX_TRIM_SEC = 5; +/** + * Playback content window `[startBin, endBin)` in bin indices — mirrors + * {@link computeWaveformSilence} (trim cap included). Use this instead of + * rounding `contentStartSec` / `contentEndSec` back to bins, which can drift + * by ±1 bin and pull trim-silence into edge/fade math. + */ +export function contentPlayBinRange( + peak: number[], + durationSec: number, + opts: WaveformSilenceOptions = {}, +): { startBin: number; endBin: number } | null { + const dur = Number.isFinite(durationSec) && durationSec > 0 ? durationSec : 0; + if (peak.length === 0 || dur <= 0) return null; + + const cut = opts.cut ?? DEFAULT_SILENCE_CUT; + const maxTrimSec = opts.maxTrimSec ?? DEFAULT_MAX_TRIM_SEC; + const n = peak.length; + const secPerBin = dur / n; + + let anyLoud = false; + for (let i = 0; i < n; i++) { + if (peak[i] > cut) { anyLoud = true; break; } + } + if (!anyLoud) return null; + + let leadBins = 0; + while (leadBins < n && peak[leadBins] <= cut) leadBins++; + let trailBins = 0; + while (trailBins < n && peak[n - 1 - trailBins] <= cut) trailBins++; + + const maxTrimBins = Math.max(0, Math.round(maxTrimSec / secPerBin)); + const startBin = Math.min(leadBins, maxTrimBins); + const endBin = Math.max(startBin + 1, n - Math.min(trailBins, maxTrimBins)); + return { startBin, endBin }; +} + /** * Dual-curve payload is peak ++ mean; use the peak half. Legacy single curve * (length === peak length) is used as-is.