mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user