Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 90 additions & 4 deletions packages/base/file-formats/audio-preview.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -72,6 +78,45 @@ export class AudioPreview extends GlimmerComponent<FilePreviewSignature> {
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));
Comment on lines +107 to +117

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Confirmation (non-blocking).

The source ordering is right: el.duration wins once metadata loads, and both the pre-metadata NaN and the live-stream Infinity cases fail the Number.isFinite(...) && > 0 guard and fall through to the extracted durationSeconds. The final Math.max(0, Math.min(1, …)) absorbs the transient case where the extract duration disagrees slightly with the media's own before metadata arrives, so playedRatio can't escape [0,1].

The one intended degradation to be aware of: when neither source yields a positive finite duration, playedRatio is pinned to 0, so has-progress never applies and the overlay never renders — the track just plays with a static full-strength waveform. That's a reasonable fallback; noting it so it reads as deliberate rather than a missed case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Confirmed. The new prefers the media element duration once metadata loads test now takes the el.duration-wins branch explicitly (finite duration of 20 over the extracted 10s), which the earlier test never did. The pinned-to-0 degradation when no source yields a positive finite duration is still deliberately unguarded — it's the intended static-waveform fallback, not a case worth a test.

};

// 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.
Expand Down Expand Up @@ -105,22 +150,51 @@ export class AudioPreview extends GlimmerComponent<FilePreviewSignature> {
<MusicIcon class='wave-glyph' width='26' height='26' />
{{/if}}
{{#if this.duration}}
<span class='wave-clock' data-test-audio-duration>{{this.duration}}</span>
<span
class='wave-clock'
data-test-audio-duration
>{{this.duration}}</span>
{{/if}}
</div>
{{else}}
<div class='audio' data-mode={{@mode}} data-test-audio-preview>
<div class='audio-visual'>
{{#if this.hasWaveform}}
<svg
class='wave-svg'
class={{cn 'wave-svg' has-progress=this.hasPlayed}}
viewBox='0 0 100 100'
preserveAspectRatio='none'
aria-hidden='true'
>
{{#each this.waveBars as |bar|}}
<rect x={{bar.x}} y={{bar.y}} width={{bar.w}} height={{bar.h}} />
<rect
x={{bar.x}}
y={{bar.y}}
width={{bar.w}}
height={{bar.h}}
/>
{{/each}}
{{#if this.hasPlayed}}
<g
class='wave-played'
clip-path={{this.playedClip}}
data-test-audio-waveform-played={{this.playedWidth}}
>
{{#each this.waveBars as |bar|}}
<rect
x={{bar.x}}
y={{bar.y}}
width={{bar.w}}
height={{bar.h}}
/>
{{/each}}
</g>
<defs>
<clipPath id={{this.clipId}}>
<rect x='0' y='0' width={{this.playedWidth}} height='100' />

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Confirmation (non-blocking), and a guard-rail for the next editor.

This one clip rect is what makes the whole feature correct: width={{this.playedWidth}} = ratio * 100 marks the playback position only because the bars are uniformly time-spaced. waveBars places bar i at x = i * (100 / n) across the 0–100 viewBox, and the envelope is a uniform resample of the full track, so x is linear in playback time; with clipPathUnits defaulting to userSpaceOnUse, the 0 → playedWidth window covers exactly the played span, partial trailing bar included.

The fragility to flag: if the bar layout ever becomes non-uniform (log/mel spacing, silence-trimmed edges, a variable slot width), this mapping breaks silently — the highlight would drift from the true position with no test failing, because nothing here ties playedWidth back to the bar geometry. Nothing to change now; just the assumption to keep in mind whenever waveBars is touched.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Agreed, and the guard-rail is worth keeping visible. The new reset-to-rest and media-duration tests exercise playedWidth end-to-end but still don't tie it back to the bar geometry — they'd keep passing if waveBars moved to non-uniform spacing while the clip stayed linear. So this assumption remains the thing to re-check whenever waveBars changes; nothing to do on this PR.

</clipPath>
</defs>
{{/if}}
</svg>
{{else}}
<div class='audio-noviz'>
Expand Down Expand Up @@ -162,6 +236,9 @@ export class AudioPreview extends GlimmerComponent<FilePreviewSignature> {
controls
preload='metadata'
data-test-audio-player
{{on 'timeupdate' this.updatePlayed}}
{{on 'seeking' this.updatePlayed}}
{{on 'emptied' this.updatePlayed}}
></audio>
{{else}}
<p class='audio-empty'>No audio source</p>
Expand Down Expand Up @@ -236,6 +313,15 @@ export class AudioPreview extends GlimmerComponent<FilePreviewSignature> {
.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%;
Expand Down
4 changes: 2 additions & 2 deletions packages/base/file-formats/file-view-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/base/mp3-audio-def.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
};
Expand Down
36 changes: 19 additions & 17 deletions packages/base/mp3-meta-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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) /
Expand Down
Loading
Loading