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.
|
- **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).
|
- **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 { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
|
||||||
type RecorderState = 'idle' | 'recording' | 'preview';
|
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
|
||||||
|
|
||||||
interface VoiceRecorderProps {
|
interface VoiceRecorderProps {
|
||||||
onSend: (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => void;
|
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 analyserRef = useRef<AnalyserNode | null>(null);
|
||||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||||
const rawSamplesRef = useRef<number[]>([]);
|
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 animFrameRef = useRef<number>(0);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
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 previewAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const [previewPlaying, setPreviewPlaying] = useState(false);
|
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 (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) {
|
if (audioCtxRef.current) {
|
||||||
audioCtxRef.current.close();
|
audioCtxRef.current.close();
|
||||||
audioCtxRef.current = null;
|
audioCtxRef.current = null;
|
||||||
}
|
}
|
||||||
analyserRef.current = null;
|
analyserRef.current = null;
|
||||||
}, []);
|
}, [stopMeters]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -85,7 +117,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
mediaRecorderRef.current = mr;
|
mediaRecorderRef.current = mr;
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
rawSamplesRef.current = [];
|
rawSamplesRef.current = [];
|
||||||
startTimeRef.current = Date.now();
|
accumulatedMsRef.current = 0;
|
||||||
|
segmentStartRef.current = Date.now();
|
||||||
|
|
||||||
const audioCtx = new AudioContext();
|
const audioCtx = new AudioContext();
|
||||||
audioCtxRef.current = audioCtx;
|
audioCtxRef.current = audioCtx;
|
||||||
@@ -95,24 +128,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
source.connect(analyser);
|
source.connect(analyser);
|
||||||
analyserRef.current = analyser;
|
analyserRef.current = analyser;
|
||||||
|
|
||||||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
startMeters();
|
||||||
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);
|
|
||||||
|
|
||||||
mr.ondataavailable = (e) => {
|
mr.ondataavailable = (e) => {
|
||||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||||
@@ -121,7 +137,6 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
mr.onstop = () => {
|
mr.onstop = () => {
|
||||||
stream.getTracks().forEach((t) => t.stop());
|
stream.getTracks().forEach((t) => t.stop());
|
||||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||||
previewDurationRef.current = Date.now() - startTimeRef.current;
|
|
||||||
setPreviewBlob(blob);
|
setPreviewBlob(blob);
|
||||||
setPreviewUrl((prev) => {
|
setPreviewUrl((prev) => {
|
||||||
if (prev) URL.revokeObjectURL(prev);
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
@@ -135,19 +150,41 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
} catch {
|
} catch {
|
||||||
onError?.('Microphone access denied');
|
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 stopRecording = useCallback(() => {
|
||||||
|
const mr = mediaRecorderRef.current;
|
||||||
|
const activeMs = mr?.state === 'recording' ? Date.now() - segmentStartRef.current : 0;
|
||||||
|
previewDurationRef.current = accumulatedMsRef.current + activeMs;
|
||||||
stopAll();
|
stopAll();
|
||||||
if (mediaRecorderRef.current?.state === 'recording') {
|
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
||||||
mediaRecorderRef.current.stop();
|
mr.stop();
|
||||||
}
|
}
|
||||||
}, [stopAll]);
|
}, [stopAll]);
|
||||||
|
|
||||||
const cancelRecording = useCallback(() => {
|
const cancelRecording = useCallback(() => {
|
||||||
stopAll();
|
stopAll();
|
||||||
const mr = mediaRecorderRef.current;
|
const mr = mediaRecorderRef.current;
|
||||||
if (mr?.state === 'recording') {
|
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
||||||
mr.ondataavailable = null;
|
mr.ondataavailable = null;
|
||||||
mr.onstop = null;
|
mr.onstop = null;
|
||||||
mr.stop();
|
mr.stop();
|
||||||
@@ -158,6 +195,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
rawSamplesRef.current = [];
|
rawSamplesRef.current = [];
|
||||||
|
accumulatedMsRef.current = 0;
|
||||||
|
segmentStartRef.current = 0;
|
||||||
setWaveformBars(Array(WAVEFORM_BARS).fill(0));
|
setWaveformBars(Array(WAVEFORM_BARS).fill(0));
|
||||||
setDurationMs(0);
|
setDurationMs(0);
|
||||||
setState('idle');
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
data-voice-recorder="recording"
|
data-voice-recorder={paused ? 'paused' : 'recording'}
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
gap="200"
|
gap="200"
|
||||||
style={{
|
style={{
|
||||||
@@ -209,13 +249,15 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: lotusTerminal ? 'var(--lt-accent-orange)' : color.Critical.Main,
|
background: lotusTerminal ? 'var(--lt-accent-orange)' : color.Critical.Main,
|
||||||
flexShrink: 0,
|
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
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
role="timer"
|
role="timer"
|
||||||
aria-label={`Recording duration ${formatDuration(durationMs)}`}
|
aria-label={`${paused ? 'Paused' : 'Recording'}, duration ${formatDuration(durationMs)}`}
|
||||||
style={{
|
style={{
|
||||||
minWidth: toRem(32),
|
minWidth: toRem(32),
|
||||||
fontVariantNumeric: 'tabular-nums',
|
fontVariantNumeric: 'tabular-nums',
|
||||||
@@ -249,6 +291,17 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</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
|
<IconButton
|
||||||
onClick={stopRecording}
|
onClick={stopRecording}
|
||||||
aria-label="Stop recording"
|
aria-label="Stop recording"
|
||||||
@@ -258,7 +311,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
radii="300"
|
radii="300"
|
||||||
title="Stop recording"
|
title="Stop recording"
|
||||||
>
|
>
|
||||||
<Icon src={Icons.Pause} size="100" />
|
<Icon src={Icons.Check} size="100" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={cancelRecording}
|
onClick={cancelRecording}
|
||||||
|
|||||||
Reference in New Issue
Block a user