Voice Message Audio Decoding#
Element records and sends voice messages as Opus/Ogg audio, at 48 kHz mono with a 24 kbps bitrate for voice and 96 kbps for high-quality audio . On the receiving side, the Playback class in src/audio/Playback.ts owns the full decode-and-playback pipeline. The event content schema is defined in createVoiceMessageContent, which follows MSC3245/MSC1767 and names the file Voice message.ogg .
Decode Pipeline#
Playback.prepare() is the entry point for decoding. It handles two cases based on file size:
- Files > 5 MB — decoded through an
<audio>element to avoid holding a large decoded buffer in memory . This path does not produce a waveform. - Files ≤ 5 MB — decoded via
AudioContext.decodeAudioData(), which is the path for all normal voice messages .
Safari / Opus Fallback#
Safari does not natively support Opus/Ogg decoding. When decodeAudioData fails, the error handler in Playback.prepare() calls decodeOgg() from src/audio/compat.ts, which converts the Ogg/Opus buffer to WAV using two Web Workers from the opus-recorder library:
decoderWorker— decodes the Opus/Ogg pages to raw PCM Float32 buffers .wavWorker— re-encodes those buffers to a 24-bit WAV at 48 kHz .
The resulting WAV ArrayBuffer is passed back to decodeAudioData for a second decode attempt .
ArrayBuffer Detachment#
AudioContext.decodeAudioData() transfers (detaches) the ArrayBuffer it receives — after the call, buf.byteLength is 0 and the buffer cannot be re-read . The Playback constructor captures this.fileSize = this.buf.byteLength before any decode attempt to preserve this metadata .
The same issue applies when passing data to Web Workers via postMessage with a transferable list. In decodeOgg(), decoded PCM pages are transferred to wavWorker as transferable objects , which is correct and intentional for performance. However, when computing the playback waveform, PlaybackEncoder.getPlaybackWaveform() deliberately avoids transferring the Float32Array by copying it with Array.from(input) before sending to the worker — this prevents the detachment from corrupting the AudioBuffer's channel data in the main thread .
Rule of thumb: Only pass
ArrayBufferas a transferable when the main thread no longer needs it. UseArray.from()or.slice()to copy when the data must remain accessible.
Waveform Generation#
After a successful decode, Playback.prepare() calls PlaybackEncoder.instance.getPlaybackWaveform() with the first channel of the decoded AudioBuffer. This is a singleton that dispatches work to src/workers/playback.worker.ts via WorkerManager.
The worker:
- Converts all amplitudes to absolute values .
- Resamples to
PLAYBACK_WAVEFORM_SAMPLES(39) using a smoothing algorithm . - Rescales to the
[0, 1]range and posts the result back .
Key Files#
| File | Role |
|---|---|
src/audio/Playback.ts | Main decode/playback class; holds the retry logic |
src/audio/compat.ts | decodeOgg() — Opus→WAV conversion via workers; createAudioContext() cross-browser helper |
src/audio/VoiceRecording.ts | Recording class; produces the Ogg file sent over Matrix |
src/PlaybackEncoder.ts | Singleton; sends waveform data to the playback worker |
src/workers/playback.worker.ts | Worker: resamples & rescales decoded channel data into a waveform |
src/WorkerManager.ts | Generic seq-based promise wrapper for postMessage worker comms |
src/utils/createVoiceMessageContent.ts | Matrix event content builder (MSC3245/MSC1767) |