From 9010dc902b4dcd19e06a4aaf691516314840d00e Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 18 Aug 2026 15:14:49 -0400 Subject: [PATCH] Scale waveform bars from stored amplitudes to the renderer's range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every envelope producer persists bars as 0..1 amplitudes — decoded RMS of float samples, MP3's peak-normalized side-info envelope, the WAV streaming envelope — while AudioPreview draws bar heights from 0-100 percentages with a minimum sliver height. The projection clamped values into [0, 100] but never rescaled, so a full-scale 1.0 bar computed a height below the sliver minimum and every waveform rendered as an identical flat dashed line, indistinguishable from silence. waveformBarsFor now scales by 100 before clamping. No stored data ever used the 0-100 scale the clamp implied, so there is no compatibility concern. Tests pin the contract: a full-scale bar projects to 100, out-of-range values clamp, and resampling operates on the projected scale. Fixes CS-12556. Co-Authored-By: Claude Fable 5 --- packages/base/file-formats/file-view-model.ts | 8 ++++- .../host/tests/unit/file-view-model-test.ts | 32 +++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/base/file-formats/file-view-model.ts b/packages/base/file-formats/file-view-model.ts index 58766c6ff51..a54f513fc2d 100644 --- a/packages/base/file-formats/file-view-model.ts +++ b/packages/base/file-formats/file-view-model.ts @@ -195,12 +195,18 @@ function waveformBarsFor(model: FileModelLike, format: FileFormat): number[] { if (!Array.isArray(values)) { return []; } + // Every producer persists bars as 0..1 amplitudes — decoded RMS of float + // samples, MP3's side-info envelope normalized to its own peak, the WAV + // streaming envelope — while the renderers draw bar heights from 0–100. + // The projection owns that scale conversion; a renderer given raw + // amplitudes would crush every bar to its minimum sliver height and draw + // silence. let normalized = values .filter( (value): value is number => typeof value === 'number' && Number.isFinite(value), ) - .map((value) => Math.max(0, Math.min(100, value))); + .map((value) => Math.max(0, Math.min(100, value * 100))); let budget = format === 'fitted' ? FITTED_WAVEFORM_BAR_BUDGET diff --git a/packages/host/tests/unit/file-view-model-test.ts b/packages/host/tests/unit/file-view-model-test.ts index 9dbea95be48..d8244094d2c 100644 --- a/packages/host/tests/unit/file-view-model-test.ts +++ b/packages/host/tests/unit/file-view-model-test.ts @@ -259,9 +259,12 @@ module('Unit | file-formats', function (hooks) { }); // Resampling has to sample across the whole signal: returning the first N - // bars would show only the opening seconds of the waveform. + // bars would show only the opening seconds of the waveform. Stored bars + // are 0..1 amplitudes; the projection scales them to the 0–100 range the + // renderers draw from. test('resamples the waveform across the whole envelope', function (assert) { - let bars = Array.from({ length: 1000 }, (_, i) => i % 101); + // Quarter steps stay exact through the x100 scaling. + let bars = Array.from({ length: 1000 }, (_, i) => (i % 5) / 4); let model = { name: 'take.wav', waveform: { barsJson: JSON.stringify(bars) }, @@ -272,10 +275,10 @@ module('Unit | file-formats', function (hooks) { fitted.waveformBars.length, FITTED_WAVEFORM_BAR_BUDGET, ); - assert.strictEqual(fitted.waveformBars[0], bars[0]); + assert.strictEqual(fitted.waveformBars[0], bars[0]! * 100); assert.strictEqual( fitted.waveformBars.at(-1), - bars.at(-1), + bars.at(-1)! * 100, 'the last bar comes from the end of the signal, not from bar 64', ); @@ -285,13 +288,28 @@ module('Unit | file-formats', function (hooks) { ); }); - test('leaves a waveform shorter than the budget untouched', function (assert) { - let bars = [1, 2, 3, 4]; + test('a waveform shorter than the budget is scaled but not resampled', function (assert) { + let bars = [0.25, 0.5, 0.75, 1]; let vm = fileViewModel( { name: 'blip.wav', waveform: { barsJson: JSON.stringify(bars) } }, 'fitted', ); - assert.deepEqual(vm.waveformBars, bars); + assert.deepEqual(vm.waveformBars, [25, 50, 75, 100]); + }); + + // The producers' contract is 0..1 (RMS amplitudes; MP3's peak-normalized + // envelope). A full-scale bar must project to full height — this is the + // difference between a waveform and a flat line of minimum-height slivers + // — and out-of-range values clamp rather than distort the scale. + test('amplitude bars project to the renderer percentage scale', function (assert) { + let vm = fileViewModel( + { + name: 'loud.mp3', + waveform: { barsJson: JSON.stringify([0, 0.5, 1, 1.2, -0.5]) }, + }, + 'fitted', + ); + assert.deepEqual(vm.waveformBars, [0, 50, 100, 100, 0]); }); test('survives waveform data that is not parseable', function (assert) {