feat(voice): render the waveform on playback with click/drag/keyboard scrubbing

Voice messages carry an MSC1767 waveform (org.matrix.msc1767.audio.waveform) and the
recorder draws a live one, but AudioContent playback only showed a plain seek bar.
Now the player renders the waveform as bars that fill with the accent (TDS green under
Lotus Terminal) as the clip plays, and the waveform itself is the seek control —
click, drag, or keyboard (arrows +/-5s, Home/End) with role=slider + ARIA value text.

- AudioContent: new optional "waveform" prop + a WaveformSeek sub-component
  (downsamples to 48 bars, mirrors the recorder's bar styling); falls back to the
  plain Range seek bar when there's no waveform.
- Threaded through MAudio (RenderAudioContentProps) so timeline voice messages get it
  automatically; the Media Gallery Audio tab passes it directly.

Improves both the timeline and the new gallery Audio tab at once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 21:50:08 -04:00
co-authored by Claude Opus 4.8
parent 2d0c804abc
commit 56863d2649
4 changed files with 160 additions and 6 deletions
@@ -411,6 +411,7 @@ type RenderAudioContentProps = {
mimeType: string;
url: string;
encInfo?: IEncryptedFile;
waveform?: number[];
};
type MAudioProps = {
content: IAudioContent;
@@ -431,6 +432,9 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
}
const filename = content.filename ?? content.body ?? 'Audio';
const waveform = (
content as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } }
)['org.matrix.msc1767.audio']?.waveform;
return (
<Attachment outlined={outlined}>
<AttachmentHeader>
@@ -454,6 +458,7 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
mimeType: safeMimeType,
url: mxcUrl,
encInfo: content.file,
waveform,
})}
</AttachmentContent>
</AttachmentBox>
@@ -1,8 +1,21 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Badge,
Chip,
color,
Icon,
IconButton,
Icons,
ProgressBar,
Spinner,
Text,
toRem,
} from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common';
import {
@@ -28,6 +41,123 @@ const PLAY_TIME_THROTTLE_OPS = {
immediate: true,
};
const WAVEFORM_DISPLAY_BARS = 48;
/** Reduce a stored MSC1767 waveform (up to ~100 samples) to a fixed display count. */
export function downsampleWaveform(waveform: number[], count: number): number[] {
if (waveform.length <= count) return waveform;
const out: number[] = [];
const step = waveform.length / count;
for (let i = 0; i < count; i += 1) {
out.push(waveform[Math.floor(i * step)] ?? 0);
}
return out;
}
// Voice-message waveform that doubles as a seek control: bars fill with the accent
// as the clip plays; click/drag/keyboard scrubs. Mirrors the recorder's bar styling
// (VoiceMessageRecorder). Falls back to the plain seek Range when there's no waveform.
function WaveformSeek({
waveform,
currentTime,
duration,
onSeek,
}: {
waveform: number[];
currentTime: number;
duration: number;
onSeek: (time: number) => void;
}) {
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const containerRef = useRef<HTMLDivElement>(null);
const bars = useMemo(() => downsampleWaveform(waveform, WAVEFORM_DISPLAY_BARS), [waveform]);
const barMax = useMemo(() => Math.max(...bars, 1), [bars]);
const progress = duration > 0 ? Math.min(1, Math.max(0, currentTime / duration)) : 0;
const seekFromClientX = useCallback(
(clientX: number) => {
const el = containerRef.current;
if (!el || duration <= 0) return;
const rect = el.getBoundingClientRect();
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
onSeek(Math.min(duration, Math.max(0, ratio * duration)));
},
[duration, onSeek],
);
const handlePointerDown = (evt: React.PointerEvent<HTMLDivElement>) => {
evt.currentTarget.setPointerCapture(evt.pointerId);
seekFromClientX(evt.clientX);
};
const handlePointerMove = (evt: React.PointerEvent<HTMLDivElement>) => {
if (evt.currentTarget.hasPointerCapture(evt.pointerId)) seekFromClientX(evt.clientX);
};
const handleKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => {
if (duration <= 0) return;
if (evt.key === 'ArrowRight' || evt.key === 'ArrowUp') {
evt.preventDefault();
onSeek(Math.min(duration, currentTime + 5));
} else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') {
evt.preventDefault();
onSeek(Math.max(0, currentTime - 5));
} else if (evt.key === 'Home') {
evt.preventDefault();
onSeek(0);
} else if (evt.key === 'End') {
evt.preventDefault();
onSeek(duration);
}
};
return (
<div
ref={containerRef}
role="slider"
tabIndex={0}
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={Math.round(duration)}
aria-valuenow={Math.round(currentTime)}
aria-valuetext={`${secondsToMinutesAndSeconds(currentTime)} of ${secondsToMinutesAndSeconds(
duration,
)}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onKeyDown={handleKeyDown}
style={{
display: 'flex',
alignItems: 'center',
gap: toRem(2),
width: '100%',
height: toRem(24),
cursor: 'pointer',
touchAction: 'none',
}}
>
{bars.map((v, i) => {
const played = bars.length > 0 && i / bars.length <= progress;
return (
<div
key={i}
style={{
flex: 1,
minWidth: toRem(2),
height: toRem(2 + (v / barMax) * 16),
borderRadius: toRem(1),
background: played
? lotusTerminal
? 'var(--lt-accent-green)'
: color.Primary.Main
: color.SurfaceVariant.ContainerActive,
transition: 'background 0.1s',
}}
/>
);
})}
</div>
);
}
type RenderMediaControlProps = {
after: ReactNode;
leftControl: ReactNode;
@@ -39,6 +169,8 @@ export type AudioContentProps = {
url: string;
info: IAudioInfo;
encInfo?: EncryptedAttachmentInfo;
/** MSC1767 voice waveform (01024 ints); when present, the seek bar renders it. */
waveform?: number[];
renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
};
export function AudioContent({
@@ -46,6 +178,7 @@ export function AudioContent({
url,
info,
encInfo,
waveform,
renderMediaControl,
}: AudioContentProps) {
const mx = useMatrixClient();
@@ -132,8 +265,17 @@ export function AudioContent({
}
};
const hasWaveform = !!waveform && waveform.length > 0 && duration > 0;
return renderMediaControl({
after: (
after: hasWaveform ? (
<WaveformSeek
waveform={waveform ?? []}
currentTime={currentTime}
duration={duration}
onSeek={seek}
/>
) : (
<Range
step={1}
min={0}
+4
View File
@@ -908,6 +908,9 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
// audio/ogg) so the decrypted blob actually plays.
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg');
const filename = body.includes('.') ? body : `${body}.${mimeTypeToExt(mimeType)}`;
const waveform = (
c as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } }
)['org.matrix.msc1767.audio']?.waveform;
return (
<Box
key={mEvent.getId()}
@@ -956,6 +959,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
url={url}
info={c.info ?? {}}
encInfo={c.file}
waveform={waveform}
renderMediaControl={(p) => <MediaControl {...p} />}
/>
</Box>