Skip to main content

Voice Narration

Overview

Voice Narration allows users to attach an audio recording to their posts, adding a personal, human layer to written content. This feature is designed with CommonPlace's calm principles in mind: audio never autoplays, always uses preload="none", and playback stops with a gentle fade-out when the player scrolls out of view.

The system consists of three main parts: the VoiceContext that manages global audio settings and device selection, the VoiceRecorder for capturing audio during post composition, and the VoicePlayer for playback within post cards. Users can select their preferred microphone and speaker devices, and can globally disable all audio playback.

Voice recordings are capped at 60 seconds by default and are stored in a dedicated Supabase storage bucket (post-voices). The recorder supports multiple audio formats (WebM with Opus, MP4, OGG) and automatically selects the best format supported by the browser.

Relevant Invariants

  • Invariant #9: "Time Is Not Weaponized" -- No autoplay, no typing indicators for voice recording. Audio is purely opt-in by the listener.
  • Invariant #2: "Calm Is the Default State" -- Audio is preload="none" and fades out when scrolled away rather than cutting abruptly.
  • Invariant #18: "Defaults Are Moral Decisions" -- Audio is disabled-by-default for playback (if the user has set that preference), and voice notes are always optional.

User Experience

Recording Flow

  1. Within the post composer (quick or advanced), a "Add voice (optional)" button is shown.
  2. Clicking it requests microphone access (if not already granted).
  3. A recording interface appears with a red recording dot, elapsed time, and maximum duration indicator.
  4. The user clicks "Stop" to end recording (or recording auto-stops at the max duration).
  5. The recording is immediately attached to the post with a preview player showing the duration.
  6. The user can re-record or remove the voice note before posting.

Playback Flow

  1. Posts with voice notes show a small volume icon in the post header.
  2. A play button with duration display appears within the post card.
  3. Clicking play starts playback. A progress bar shows the current position.
  4. If the player scrolls out of view (less than 10% visible), playback fades out over ~500ms and pauses.
  5. Users can set their preferred output device in voice settings.
  6. Users who have globally disabled audio will not see voice players at all.

Technical Implementation

Key Files

FilePurpose
src/components/voice/VoiceRecorder.jsxRecording component with MediaRecorder API, timer, max duration enforcement, re-record, and remove functionality (248 lines)
src/components/voice/VoicePlayer.jsxPlayback component with play/pause toggle, progress bar, compact mode, fade-out on scroll, and device output selection (155 lines)
src/components/voice/VoiceContext.jsxReact context providing global audio settings: audio disabled toggle, device selection, device enumeration (158 lines)

VoiceRecorder

The recorder uses the browser's MediaRecorder API with the following configuration:

  • Audio constraints: echoCancellation: true, noiseSuppression: true, autoGainControl: true
  • MIME type selection: Tries formats in order: audio/webm;codecs=opus, audio/webm, audio/mp4, audio/ogg;codecs=opus, audio/ogg, then browser default
  • Data collection: start(1000) requests data every second for reliable recording
  • Timer: setInterval increments duration every second; auto-stops at maxDuration (default 60 seconds)
  • Output: Calls onVoiceReady({ blob, url, duration, mimeType }) when stopped

States: idle (show record button), recording (show timer + stop), attached (show preview + re-record/remove), unsupported (render nothing)

VoicePlayer

The player uses a standard <audio> element with preload="none":

  • Compact mode: Small inline button with duration badge and progress overlay
  • Full mode: Larger play button with "Voice" label, duration, and separate progress bar
  • Scroll fade-out: Uses IntersectionObserver (threshold 0.1) to detect when the player leaves the viewport. When playing and less than 10% visible, volume fades from 1.0 to 0 in steps of 0.1 every 50ms, then pauses.
  • Output device: Uses setSinkId() API when available to route audio to the user's preferred output device.
  • Global disable: If audioDisabled is true in VoiceContext, the player renders nothing.

VoiceContext

The context manages:

  • Audio disabled preference: Stored in the profiles.viewer_disable_audio column. Toggled via toggleAudio().
  • Input/output device selection: Stored in localStorage (voice_input_device, voice_output_device). Devices are enumerated on mount without requesting permission; labels become available after the first recording grants permission.
  • Device change listener: Listens for devicechange events to update available devices when hardware changes.

Storage

Voice recordings are uploaded to the post-voices Supabase storage bucket with the path format {user_id}/{timestamp}-voice.{ext}. The public URL is stored in the post's voice_url column, with voice_duration_ms and voice_mime as metadata.

Database Columns (on posts table)

ColumnTypePurpose
voice_urltextPublic URL of the voice recording
voice_duration_msintegerDuration in milliseconds
voice_mimetextMIME type of the recording

Edge Cases

ScenarioBehavior
Browser doesn't support MediaRecorderRecorder state set to unsupported; component renders nothing
Microphone access deniedError message: "Microphone access denied. Please allow microphone access."
No microphone foundError message: "No microphone found."
Recording exceeds max durationAuto-stopped at the limit; duration capped
Voice upload failsError is logged but post creation continues without the voice note
User has audio globally disabledVoicePlayer returns null; voice indicator still shows in post header
Player scrolls out of view while playingAudio fades out over ~500ms, then pauses; volume resets to 1.0 for next play
setSinkId not supported by browserFallback to default output device; warning logged

Last updated: 2026-02-07