fix(app): record voice messages in a widely playable container (#1973)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-24 12:55:53 +02:00
1 parent bf11445299
commit fdd12194b1
1 file changed
+54 -6
@@ -6,7 +6,11 @@ import styles from '@app/features/channel/components/VoiceMessageRecorder.module
import {Logger} from '@app/features/platform/utils/AppLogger';
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
import {getReducedMotionProps} from '@app/features/ui/utils/ReducedMotionAnimation';
import {prepareVoiceMessageWav} from '@app/features/voice/utils/VoiceMessageRecordingUtils';
import {
computeVoiceWaveformFromAudioBuffer,
type PreparedVoiceMessage,
prepareVoiceMessageWav,
} from '@app/features/voice/utils/VoiceMessageRecordingUtils';
import {sendVoiceMessage} from '@app/features/voice/utils/VoiceMessageSendUtils';
import {autoUpdate, FloatingPortal, flip, offset, shift, useFloating} from '@floating-ui/react';
import {
@@ -67,6 +71,43 @@ const SEND_VOICE_MESSAGE_DESCRIPTOR = msg({
comment: 'Accessible label and tooltip for the send button after recording a voice message.',
});
const logger = new Logger('VoiceMessageRecorder');
const UNIVERSALLY_PLAYABLE_RECORDING_MIME = 'audio/mp4';
const OPUS_RECORDING_MIME_CANDIDATES = ['audio/ogg;codecs=opus', 'audio/webm;codecs=opus'] as const;
export function selectRecordingMimeType(isTypeSupported: (mimeType: string) => boolean): string | undefined {
if (isTypeSupported(UNIVERSALLY_PLAYABLE_RECORDING_MIME)) {
return UNIVERSALLY_PLAYABLE_RECORDING_MIME;
}
return OPUS_RECORDING_MIME_CANDIDATES.find((candidate) => isTypeSupported(candidate));
}
export function isUniversallyPlayableContainer(mimeType: string): boolean {
return mimeType.split(';')[0].trim().toLowerCase() === UNIVERSALLY_PLAYABLE_RECORDING_MIME;
}
async function prepareUniversalUpload(blob: Blob, filename: string): Promise<PreparedVoiceMessage> {
const contextClass =
window.AudioContext || (window as typeof window & {webkitAudioContext?: typeof AudioContext}).webkitAudioContext;
if (!contextClass) {
throw new Error('AudioContext is unavailable; cannot measure voice message');
}
const audioContext = new contextClass();
try {
const audioBuffer = await audioContext.decodeAudioData(await blob.arrayBuffer());
const {duration, waveform} = computeVoiceWaveformFromAudioBuffer(audioBuffer);
return {file: new File([blob], filename, {type: blob.type}), duration, waveform};
} finally {
audioContext.close().catch(() => {});
}
}
async function prepareVoiceMessageUpload(blob: Blob): Promise<PreparedVoiceMessage> {
const stamp = Date.now();
if (isUniversallyPlayableContainer(blob.type)) {
return prepareUniversalUpload(blob, `voice-message-${stamp}.m4a`);
}
return prepareVoiceMessageWav(blob, `voice-message-${stamp}.wav`);
}
type StopAction = 'send' | 'discard';
@@ -322,13 +363,13 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
resetState();
return;
}
const blob = new Blob(recordedChunks, {type: recorderMime || 'audio/ogg'});
const blob = new Blob(recordedChunks, {type: recorderMime || OPUS_RECORDING_MIME_CANDIDATES[0]});
setIsSending(true);
setIsRecording(false);
setIsLocked(false);
setLockPreview(false);
try {
const {file, duration, waveform} = await prepareVoiceMessageWav(blob, `voice-message-${Date.now()}.wav`);
const {file, duration, waveform} = await prepareVoiceMessageUpload(blob);
await sendVoiceMessage({
channelId,
file,
@@ -351,11 +392,15 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
showTooltip(i18n._(VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR));
return;
}
const preferredType = selectRecordingMimeType((candidate) => MediaRecorder.isTypeSupported(candidate));
if (preferredType === undefined) {
showTooltip(i18n._(VOICE_RECORDING_IS_NOT_SUPPORTED_IN_THIS_BROWSER_DESCRIPTOR));
return;
}
let acquiredStream: MediaStream | null = null;
try {
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
const preferredType = MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')
? 'audio/ogg;codecs=opus'
: 'audio/webm;codecs=opus';
acquiredStream = stream;
const recorder = new MediaRecorder(stream, {mimeType: preferredType});
chunksRef.current = [];
recorder.addEventListener('dataavailable', handleDataAvailable);
@@ -379,6 +424,9 @@ export default function VoiceMessageRecorder({channelId, disabled, tooltipAnchor
startWaveform(stream);
} catch (error) {
logger.error('Failed to start voice recording', error);
if (acquiredStream !== null && streamRef.current !== acquiredStream) {
acquiredStream.getTracks().forEach((track) => track.stop());
}
showTooltip(i18n._(UNABLE_TO_START_RECORDING_PLEASE_ALLOW_MICROPHONE_ACCESS_DESCRIPTOR));
}
}, [