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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user