feat(voice): pause / resume while recording

The voice recorder was a single continuous take — an interruption meant
stopping early or starting over (and the Stop button confusingly used a
Pause icon). Add real pause/resume.

- MediaRecorder.pause()/resume() with a new 'paused' state.
- Duration now accumulates only active-recording time: an accumulate-on-
  pause model (accumulatedMsRef + segmentStartRef) replaces the wall-clock
  startTime, so paused time is excluded from both the live timer and the
  finalized preview duration.
- Extracted startMeters/stopMeters so the waveform rAF + timer interval are
  reused across start/resume; stopMeters keeps the audio graph alive for
  resume while stopAll tears it down.
- Recording view now also renders the 'paused' state: a Pause/Resume toggle
  (Pause vs Play icon), the record dot stops pulsing (dimmed), and the
  waveform/timer freeze. Stop/Cancel/unmount all handle a paused recorder.
- Fixed the mislabeled finish button: it now shows a checkmark (it advances
  to the preview step) instead of a pause icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:58:01 -04:00
co-authored by Claude Opus 4.8
parent 87b1c1a702
commit 13304f1571
2 changed files with 88 additions and 34 deletions
+1
View File
@@ -859,6 +859,7 @@ player.kick).
- **Waveform scrubbing** — voice messages carry an MSC1767 waveform (`org.matrix.msc1767.audio.waveform`, sent by the recorder). Playback renders it as bars that fill with the accent as the clip plays (TDS green under Lotus Terminal), and the waveform **is** the seek control — click, drag, or keyboard (arrows ±5s, Home/End) to scrub (`role="slider"`, ARIA value text). Threaded through `MAudio` (`MsgTypeRenderers.tsx`) + passed directly by the gallery. Regular audio with no waveform keeps the plain seek bar.
- **Playback speed** — a cycle button (`[0.75, 1, 1.5, 2]×`); a `useEffect` sets `audioElement.playbackRate` and re-applies it on (re)load (the browser resets it).
- **Recording pause / resume** — `VoiceMessageRecorder.tsx` supports a `paused` state via `MediaRecorder.pause()/resume()`, so you can pause mid-recording and continue without a gap. The duration timer accumulates only active-recording time (paused time is excluded), the waveform/meters freeze while paused and the record dot stops pulsing, and the finish button (which advances to the preview/review step) is a checkmark distinct from the Pause control.
---
+87 -34
View File
@@ -3,7 +3,7 @@ import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds'
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
type RecorderState = 'idle' | 'recording' | 'preview';
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
interface VoiceRecorderProps {
onSend: (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => void;
@@ -45,7 +45,10 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
const analyserRef = useRef<AnalyserNode | null>(null);
const audioCtxRef = useRef<AudioContext | null>(null);
const rawSamplesRef = useRef<number[]>([]);
const startTimeRef = useRef<number>(0);
// Active-recording duration excluding paused time: accumulated ms from prior
// segments + (now - segmentStart) for the current segment.
const accumulatedMsRef = useRef<number>(0);
const segmentStartRef = useRef<number>(0);
const animFrameRef = useRef<number>(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -54,15 +57,44 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
const [previewPlaying, setPreviewPlaying] = useState(false);
const stopAll = useCallback(() => {
// Start the waveform (rAF) + duration (interval) meters against the live
// analyser. Reused by startRecording and resumeRecording.
const startMeters = useCallback(() => {
const analyser = analyserRef.current;
if (!analyser) return;
const buf = new Uint8Array(analyser.frequencyBinCount);
const tick = () => {
if (!analyserRef.current) return;
analyserRef.current.getByteFrequencyData(buf);
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
rawSamplesRef.current.push(avg);
setWaveformBars((prev) => [...prev.slice(1), Math.round((avg / 255) * 100)]);
animFrameRef.current = requestAnimationFrame(tick);
};
animFrameRef.current = requestAnimationFrame(tick);
timerRef.current = setInterval(() => {
setDurationMs(accumulatedMsRef.current + (Date.now() - segmentStartRef.current));
}, 100);
}, []);
// Stop the meters (rAF + interval) without tearing down the audio graph, so a
// paused recording can resume.
const stopMeters = useCallback(() => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
if (timerRef.current) clearInterval(timerRef.current);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const stopAll = useCallback(() => {
stopMeters();
if (audioCtxRef.current) {
audioCtxRef.current.close();
audioCtxRef.current = null;
}
analyserRef.current = null;
}, []);
}, [stopMeters]);
useEffect(
() => () => {
@@ -85,7 +117,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
mediaRecorderRef.current = mr;
chunksRef.current = [];
rawSamplesRef.current = [];
startTimeRef.current = Date.now();
accumulatedMsRef.current = 0;
segmentStartRef.current = Date.now();
const audioCtx = new AudioContext();
audioCtxRef.current = audioCtx;
@@ -95,24 +128,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
source.connect(analyser);
analyserRef.current = analyser;
const buf = new Uint8Array(analyser.frequencyBinCount);
const tick = () => {
if (!analyserRef.current) return;
analyserRef.current.getByteFrequencyData(buf);
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
rawSamplesRef.current.push(avg);
setWaveformBars((prev) => {
const next = [...prev.slice(1), Math.round((avg / 255) * 100)];
return next;
});
animFrameRef.current = requestAnimationFrame(tick);
};
animFrameRef.current = requestAnimationFrame(tick);
timerRef.current = setInterval(() => {
setDurationMs(Date.now() - startTimeRef.current);
}, 100);
startMeters();
mr.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
@@ -121,7 +137,6 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
mr.onstop = () => {
stream.getTracks().forEach((t) => t.stop());
const blob = new Blob(chunksRef.current, { type: mimeType });
previewDurationRef.current = Date.now() - startTimeRef.current;
setPreviewBlob(blob);
setPreviewUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
@@ -135,19 +150,41 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
} catch {
onError?.('Microphone access denied');
}
}, [onError]);
}, [onError, startMeters]);
const pauseRecording = useCallback(() => {
const mr = mediaRecorderRef.current;
if (mr?.state !== 'recording') return;
accumulatedMsRef.current += Date.now() - segmentStartRef.current;
setDurationMs(accumulatedMsRef.current);
stopMeters();
mr.pause();
setState('paused');
}, [stopMeters]);
const resumeRecording = useCallback(() => {
const mr = mediaRecorderRef.current;
if (mr?.state !== 'paused') return;
segmentStartRef.current = Date.now();
mr.resume();
startMeters();
setState('recording');
}, [startMeters]);
const stopRecording = useCallback(() => {
const mr = mediaRecorderRef.current;
const activeMs = mr?.state === 'recording' ? Date.now() - segmentStartRef.current : 0;
previewDurationRef.current = accumulatedMsRef.current + activeMs;
stopAll();
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
mr.stop();
}
}, [stopAll]);
const cancelRecording = useCallback(() => {
stopAll();
const mr = mediaRecorderRef.current;
if (mr?.state === 'recording') {
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
mr.ondataavailable = null;
mr.onstop = null;
mr.stop();
@@ -158,6 +195,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
return null;
});
rawSamplesRef.current = [];
accumulatedMsRef.current = 0;
segmentStartRef.current = 0;
setWaveformBars(Array(WAVEFORM_BARS).fill(0));
setDurationMs(0);
setState('idle');
@@ -187,10 +226,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
);
}
if (state === 'recording') {
if (state === 'recording' || state === 'paused') {
const paused = state === 'paused';
return (
<Box
data-voice-recorder="recording"
data-voice-recorder={paused ? 'paused' : 'recording'}
alignItems="Center"
gap="200"
style={{
@@ -209,13 +249,15 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
borderRadius: '50%',
background: lotusTerminal ? 'var(--lt-accent-orange)' : color.Critical.Main,
flexShrink: 0,
animation: 'pttLivePulse 900ms ease-in-out infinite',
// Pulse only while actively recording; hold steady (dimmed) when paused.
animation: paused ? 'none' : 'pttLivePulse 900ms ease-in-out infinite',
opacity: paused ? 0.5 : 1,
}}
/>
<Text
size="T200"
role="timer"
aria-label={`Recording duration ${formatDuration(durationMs)}`}
aria-label={`${paused ? 'Paused' : 'Recording'}, duration ${formatDuration(durationMs)}`}
style={{
minWidth: toRem(32),
fontVariantNumeric: 'tabular-nums',
@@ -249,6 +291,17 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
/>
))}
</Box>
<IconButton
onClick={paused ? resumeRecording : pauseRecording}
aria-label={paused ? 'Resume recording' : 'Pause recording'}
variant="SurfaceVariant"
fill="Soft"
size="300"
radii="300"
title={paused ? 'Resume' : 'Pause'}
>
<Icon src={paused ? Icons.Play : Icons.Pause} size="100" />
</IconButton>
<IconButton
onClick={stopRecording}
aria-label="Stop recording"
@@ -258,7 +311,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
radii="300"
title="Stop recording"
>
<Icon src={Icons.Pause} size="100" />
<Icon src={Icons.Check} size="100" />
</IconButton>
<IconButton
onClick={cancelRecording}