diff --git a/packages/base/file-formats/audio-preview.gts b/packages/base/file-formats/audio-preview.gts index bb860d14ca1..375e69c0c9a 100644 --- a/packages/base/file-formats/audio-preview.gts +++ b/packages/base/file-formats/audio-preview.gts @@ -14,14 +14,20 @@ // A fitted cell deliberately does not mount a player: a grid of live audio // elements is a page full of independent transport chrome. It shows the // waveform and the running time, and the reading formats own playback. +import { on } from '@ember/modifier'; import GlimmerComponent from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; import MusicIcon from '@cardstack/boxel-icons/music'; -import { eq } from '@cardstack/boxel-ui/helpers'; +import { cn, eq } from '@cardstack/boxel-ui/helpers'; import { formatClock } from './file-presentation'; import type { FilePreviewSignature } from './file-preview-stage'; +// SVG clipPath ids are document-global, and a page can mount several audio +// previews at once, so each instance takes its own serial. +let clipSerial = 0; + // One drawn bar of the waveform, in the 0–100 viewBox the template stretches to // fill. Centered on the mid-line so the envelope reads as a real waveform rather // than a bar chart growing from the floor. @@ -72,6 +78,45 @@ export class AudioPreview extends GlimmerComponent { return this.args.model?.mediaUrl; } + // How much of the track has played, 0–1, mirrored from the mounted player. + // Kept as a ratio rather than a time so the waveform math never cares which + // duration source (media element vs. extract) produced it. + @tracked playedRatio = 0; + + private clipId = `audio-wave-played-${++clipSerial}`; + + get playedClip(): string { + return `url(#${this.clipId})`; + } + + // Gate on the rounded width, not the raw ratio: on very long media the first + // moments of playback round to a zero-width clip window, and keying off the + // ratio there would dim the whole waveform while highlighting nothing. + get hasPlayed(): boolean { + return this.playedWidth > 0; + } + + // Width of the clip window in the 0–100 viewBox. One decimal keeps the + // attribute churn per timeupdate readable without visibly stepping. + get playedWidth(): number { + return Math.round(this.playedRatio * 1000) / 10; + } + + updatePlayed = (event: Event) => { + let el = event.currentTarget as HTMLAudioElement; + // The element's own duration wins once metadata arrives; before that (or + // in a context where the media never loads) the extracted figure stands in. + let duration = + Number.isFinite(el.duration) && el.duration > 0 + ? el.duration + : Number(this.args.model?.durationSeconds); + if (!Number.isFinite(duration) || duration <= 0) { + this.playedRatio = 0; + return; + } + this.playedRatio = Math.max(0, Math.min(1, el.currentTime / duration)); + }; + // The extracted running time. The native player reports its own once metadata // loads, but that never happens in a headless prerender and may lag a slow // range fetch, so the figure the extractor already read is shown regardless. @@ -105,7 +150,10 @@ export class AudioPreview extends GlimmerComponent { {{/if}} {{#if this.duration}} - {{this.duration}} + {{this.duration}} {{/if}} {{else}} @@ -113,14 +161,40 @@ export class AudioPreview extends GlimmerComponent {
{{#if this.hasWaveform}} {{else}}
@@ -162,6 +236,9 @@ export class AudioPreview extends GlimmerComponent { controls preload='metadata' data-test-audio-player + {{on 'timeupdate' this.updatePlayed}} + {{on 'seeking' this.updatePlayed}} + {{on 'emptied' this.updatePlayed}} > {{else}}

No audio source

@@ -236,6 +313,15 @@ export class AudioPreview extends GlimmerComponent { .audio-visual .wave-svg rect { fill: var(--fd-accent, var(--primary, #7c9dff)); } + + /* Once playback begins, the un-played remainder recedes so the + accent-colored played span reads as the position. Direct children + only: the played layer's rects sit inside their own group and keep + full strength. Before playback the class is absent, so a track at + rest keeps the waveform's usual weight. */ + .audio-visual .wave-svg.has-progress > rect { + opacity: 0.45; + } .audio-noviz { width: 100%; height: 100%; diff --git a/packages/base/file-formats/file-view-model.ts b/packages/base/file-formats/file-view-model.ts index a54f513fc2d..689acacf382 100644 --- a/packages/base/file-formats/file-view-model.ts +++ b/packages/base/file-formats/file-view-model.ts @@ -196,8 +196,8 @@ function waveformBarsFor(model: FileModelLike, format: FileFormat): number[] { 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. + // samples, MP3's side-info envelope normalized to its own loudest bar, 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. diff --git a/packages/base/mp3-audio-def.gts b/packages/base/mp3-audio-def.gts index 3c66ef53a0c..45d0022be85 100644 --- a/packages/base/mp3-audio-def.gts +++ b/packages/base/mp3-audio-def.gts @@ -87,7 +87,7 @@ export class Mp3Def extends AudioDef { ? {} : { sampleRateHz: envelope.sampleRateHz }), // A quantizer scale carries no calibrated amplitude, so the envelope is - // normalized to the track's own peak and the absolute figures are left + // normalized to its own loudest bar and the absolute figures are left // unset rather than reported on a scale that isn't comparable with a // decoded one. }; diff --git a/packages/base/mp3-meta-extractor.ts b/packages/base/mp3-meta-extractor.ts index 3a25b2fb83b..85dc11b3155 100644 --- a/packages/base/mp3-meta-extractor.ts +++ b/packages/base/mp3-meta-extractor.ts @@ -405,7 +405,7 @@ export function extractMp3Tags(bytes: Uint8Array): MediaTags | undefined { // // The tradeoff is that a quantizer scale is not calibrated amplitude. It tracks // loudness well enough to draw, but its absolute values aren't comparable with a -// decoded RMS, so the envelope is normalized to the track's own peak and the +// decoded RMS, so the envelope is normalized to its own loudest bar and the // absolute amplitude fields are left unset rather than reported wrongly. // A granule is 576 samples; a Layer III frame holds two of them (one in MPEG-2). @@ -591,12 +591,24 @@ function scanFrames( return { granuleCount, sampleRate, frameCount }; } +// Scale bars so the loudest bar reads as full scale. The reference must be the +// loudest bar, not the loudest single granule (`envelope.peak`): a bar is the +// RMS across its granules, so on a track where one transient granule dominates, +// even the bar containing it sits near 1/√(granules per bar) of that peak — +// scaling by the granule peak would collapse the whole waveform toward zero. +function normalizeBarsToLoudest(bars: number[]): number[] { + let loudest = Math.max(0, ...bars); + return loudest > 0 + ? bars.map((bar) => Math.round((bar / loudest) * 10000) / 10000) + : bars; +} + // Build an amplitude envelope from a whole MP3 without decoding it. // -// Bars are normalized to the track's own peak, because a quantizer scale has no -// absolute meaning — a renderer wants relative heights, and the calibrated -// figures a decoded envelope would carry are deliberately omitted rather than -// filled with numbers that don't mean the same thing. +// Bars are normalized to the envelope's loudest bar, because a quantizer scale +// has no absolute meaning — a renderer wants relative heights, and the +// calibrated figures a decoded envelope would carry are deliberately omitted +// rather than filled with numbers that don't mean the same thing. export function extractMp3Envelope( bytes: Uint8Array, barCount: number, @@ -615,12 +627,7 @@ export function extractMp3Envelope( return undefined; } - let peak = envelope.peak; - let bars = envelope.bars(); - let normalized = - peak > 0 - ? bars.map((bar) => Math.round((bar / peak) * 10000) / 10000) - : bars; + let normalized = normalizeBarsToLoudest(envelope.bars()); // Granules are a fixed 576 samples, so the count gives a duration that agrees // with the frame walk without needing the Xing header the duration reader @@ -742,12 +749,7 @@ export async function extractMp3EnvelopeFromStream( return undefined; } - let peak = envelope.peak; - let bars = envelope.bars(); - let normalized = - peak > 0 - ? bars.map((bar) => Math.round((bar / peak) * 10000) / 10000) - : bars; + let normalized = normalizeBarsToLoudest(envelope.bars()); let durationSeconds = sampleRate && sampleRate > 0 ? Math.round(((granuleCount * SAMPLES_PER_GRANULE) / sampleRate) * 1000) / diff --git a/packages/host/tests/integration/components/file-def-format-templates-test.gts b/packages/host/tests/integration/components/file-def-format-templates-test.gts index e9f94bf7bec..cc802afc124 100644 --- a/packages/host/tests/integration/components/file-def-format-templates-test.gts +++ b/packages/host/tests/integration/components/file-def-format-templates-test.gts @@ -1,5 +1,5 @@ import type { TemplateOnlyComponent } from '@ember/component/template-only'; -import { find } from '@ember/test-helpers'; +import { find, settled } from '@ember/test-helpers'; import type { RenderingTestContext } from '@ember/test-helpers'; import GlimmerComponent from '@glimmer/component'; @@ -13,8 +13,10 @@ import { setupBaseRealm } from '../../helpers/base-realm'; import { renderCard } from '../../helpers/render-component'; import { setupRenderingTest } from '../../helpers/setup'; +import type * as AudioDefModule from '@cardstack/base/audio-file-def'; import type * as CardApiModule from '@cardstack/base/card-api'; import type { FilePreviewSignature } from '@cardstack/base/file-formats/file-preview-stage'; +import type * as MetadataFieldsModule from '@cardstack/base/file-formats/metadata-fields'; // Stands in for a family's own glyph, which a FileDef subclass declares as // `static icon`. Annotated the way the boxel-icons modules are: `ComponentLike` @@ -284,6 +286,155 @@ module('Integration | FileDef format templates', function (hooks) { .exists('the image family renders a native inside the shell'); }); + test('the audio waveform shades the played span as playback advances', async function (assert) { + let { AudioDef } = await loader.import( + `${baseRealm.url}audio-file-def`, + ); + let { WaveformMetadataField } = await loader.import< + typeof MetadataFieldsModule + >(`${baseRealm.url}file-formats/metadata-fields`); + + let audio = new AudioDef({ + id: 'http://example.com/audio/take.wav', + url: 'http://example.com/audio/take.wav', + sourceUrl: 'http://example.com/audio/take.wav', + name: 'take.wav', + contentType: 'audio/wav', + contentSize: 2_646_078, + duration: 10, + waveform: new WaveformMetadataField({ + decodeStatus: 'ok', + barsJson: JSON.stringify(Array.from({ length: 32 }, () => 0.5)), + barCount: 32, + }), + }); + + await renderCard(loader, audio, 'isolated'); + assert + .dom('[data-test-audio-preview] .wave-svg') + .exists('the waveform renders'); + assert + .dom('[data-test-audio-waveform-played]') + .doesNotExist('a track at rest marks nothing as played'); + + let player = find('[data-test-audio-player]') as HTMLAudioElement; + // No media loads in this environment (the src 404s), so the element's own + // currentTime/duration never become usable. Shadow currentTime with an own + // property so the handler reads a definite position and falls back to the + // extracted duration, independent of media state. + Object.defineProperty(player, 'currentTime', { value: 5 }); + player.dispatchEvent(new Event('timeupdate')); + await settled(); + + assert + .dom('[data-test-audio-waveform-played]') + .exists('playback marks the played span'); + assert + .dom('[data-test-audio-waveform-played]') + .hasAttribute( + 'data-test-audio-waveform-played', + '50', + '5s into a 10s track clips the played layer at half the waveform', + ); + }); + + test('the audio waveform tears down the played span when playback resets to rest', async function (assert) { + let { AudioDef } = await loader.import( + `${baseRealm.url}audio-file-def`, + ); + let { WaveformMetadataField } = await loader.import< + typeof MetadataFieldsModule + >(`${baseRealm.url}file-formats/metadata-fields`); + + let audio = new AudioDef({ + id: 'http://example.com/audio/take.wav', + url: 'http://example.com/audio/take.wav', + sourceUrl: 'http://example.com/audio/take.wav', + name: 'take.wav', + contentType: 'audio/wav', + contentSize: 2_646_078, + duration: 10, + waveform: new WaveformMetadataField({ + decodeStatus: 'ok', + barsJson: JSON.stringify(Array.from({ length: 32 }, () => 0.5)), + barCount: 32, + }), + }); + + await renderCard(loader, audio, 'isolated'); + + let player = find('[data-test-audio-player]') as HTMLAudioElement; + // No media loads here, so back the element's currentTime with a mutable + // own property and lean on the extracted duration, as above. + let position = 5; + Object.defineProperty(player, 'currentTime', { + configurable: true, + get: () => position, + }); + player.dispatchEvent(new Event('timeupdate')); + await settled(); + assert + .dom('[data-test-audio-waveform-played]') + .exists('playback marks the played span'); + + // A seek back to the very start must drop has-progress and remove the + // overlay again — the waveform returns to its at-rest full-strength look. + position = 0; + player.dispatchEvent(new Event('seeking')); + await settled(); + assert + .dom('[data-test-audio-waveform-played]') + .doesNotExist('seeking back to the start tears the played overlay down'); + }); + + test('the audio waveform prefers the media element duration once metadata loads', async function (assert) { + let { AudioDef } = await loader.import( + `${baseRealm.url}audio-file-def`, + ); + let { WaveformMetadataField } = await loader.import< + typeof MetadataFieldsModule + >(`${baseRealm.url}file-formats/metadata-fields`); + + let audio = new AudioDef({ + id: 'http://example.com/audio/take.wav', + url: 'http://example.com/audio/take.wav', + sourceUrl: 'http://example.com/audio/take.wav', + name: 'take.wav', + contentType: 'audio/wav', + contentSize: 2_646_078, + duration: 10, + waveform: new WaveformMetadataField({ + decodeStatus: 'ok', + barsJson: JSON.stringify(Array.from({ length: 32 }, () => 0.5)), + barCount: 32, + }), + }); + + await renderCard(loader, audio, 'isolated'); + + let player = find('[data-test-audio-player]') as HTMLAudioElement; + // Metadata has loaded: the element reports its own duration, which must win + // over the extracted 10s. 5s of a 20s media track is a quarter, not the + // half the extracted duration would produce. + Object.defineProperty(player, 'duration', { + configurable: true, + value: 20, + }); + Object.defineProperty(player, 'currentTime', { + configurable: true, + value: 5, + }); + player.dispatchEvent(new Event('timeupdate')); + await settled(); + assert + .dom('[data-test-audio-waveform-played]') + .hasAttribute( + 'data-test-audio-waveform-played', + '25', + "the element's 20s duration wins over the extracted 10s", + ); + }); + test('an isolated image fills its stage frame rather than overflowing it', async function (assert) { // A small image carries its intrinsic pixel dimensions as the 's // width/height attributes. If the renderer's `height: 100%` fails to resolve diff --git a/packages/host/tests/unit/audio-metadata-extractor-test.ts b/packages/host/tests/unit/audio-metadata-extractor-test.ts index d04a40d61e7..3f1ab49e71b 100644 --- a/packages/host/tests/unit/audio-metadata-extractor-test.ts +++ b/packages/host/tests/unit/audio-metadata-extractor-test.ts @@ -1368,7 +1368,29 @@ module('Unit | audio metadata extractors', function (hooks) { assert.strictEqual( bars[3], 1, - 'bars are normalized to the track peak, so the loudest reaches 1', + 'bars are normalized to the loudest bar, so the loudest reaches 1', + ); + }); + + test('a single transient still leaves the loudest bar at full scale', function (assert) { + // The spike frame's granules share their bar with quiet granules, so + // that bar's RMS sits well below the spike's own amplitude. The bars + // must be normalized to the loudest BAR: scaling by the loudest single + // granule instead would leave every bar — this one included — far below + // full scale, rendering the whole waveform as near-zero slivers. + let frames = Array.from({ length: 16 }, (_, index) => + frameWithGain(index === 12 ? 230 : 170), + ).flat(); + let envelope = extractMp3Envelope(new Uint8Array(frames), 4); + let bars = envelope!.bars; + assert.strictEqual( + Math.max(...bars), + 1, + 'the loudest bar reaches full scale', + ); + assert.true( + bars[3]! > bars[0]!, + 'the bar holding the transient is the tallest', ); });