From fdd12194b18ab5d2cce54d8571a61599b257248d Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:55:53 +0200 Subject: [PATCH] fix(app): record voice messages in a widely playable container (#1973) --- .../components/VoiceMessageRecorder.tsx | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/fluxer_app/src/features/channel/components/VoiceMessageRecorder.tsx b/fluxer_app/src/features/channel/components/VoiceMessageRecorder.tsx index c671b5e49..d7bea5867 100644 --- a/fluxer_app/src/features/channel/components/VoiceMessageRecorder.tsx +++ b/fluxer_app/src/features/channel/components/VoiceMessageRecorder.tsx @@ -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 { + 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 { + 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)); } }, [