fix(autodj): exclude trim-silence bins from edge span and fade length

Edge analysis rounded contentStart/End back to bins, which could land
inside trim-silence. With the 5 s trim cap, playback seeks partway through
a long digital tail but bins between the seek point and real music were still
counted in edge.seconds — empty tail/head time leaked into fade duration.

Anchor walks at the first/last audible bin (peak > cut) inside the playback
window, skip trim-silence in run/shape collection, and measure plateau only
over playable content. Adds contentPlayBinRange() shared with computeWaveformSilence.
This commit is contained in:
cucadmuh
2026-06-24 17:42:40 +03:00
parent e770656a74
commit 6e6006408d
4 changed files with 103 additions and 32 deletions
+20
View File
@@ -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 1029
// 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();
+38 -31
View File
@@ -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] });
}
+9 -1
View File
@@ -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);
+36
View File
@@ -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.