# How to Add Voice Note Sharing to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

Voice note sharing needs four pieces: the browser MediaRecorder API for recording, WaveSurfer.js for waveform visualization, Supabase Storage for the audio file, and a Deepgram or Whisper Edge Function for auto-transcription. With Lovable or V0 you can ship a working feature in 6–10 hours. Running cost is $0–$5/month at 100 users, rising to $10–$30/month at 1,000 as storage and transcription costs accumulate.

## What Voice Note Sharing Actually Is

Voice note sharing lets users record short audio clips in the browser and attach them to messages, feed posts, or comments — no app download required. The browser MediaRecorder API handles recording natively, WaveSurfer.js renders the live waveform during capture and a scrubable playback bar afterward, and Supabase Storage holds the WebM audio file. The feature is advanced not because any single piece is hard, but because five layers (permissions, encoding, upload, transcription, playback) all have to work together — and each has its own failure mode on Safari, in Lovable preview, and during Next.js SSR.

## Anatomy of the Feature

Six components across four layers. The MediaRecorder and WaveSurfer.js pieces are where AI-generated builds most often go wrong.

- **MediaRecorder API layer** (ui): Requests microphone access via navigator.mediaDevices.getUserMedia() and feeds the audio stream into a MediaRecorder instance. The ondataavailable callback fires every 250ms, accumulating audio chunks that are later assembled into a single Blob for upload.
- **Waveform visualizer** (ui): WaveSurfer.js (npm: wavesurfer.js) renders both the live recording waveform via AudioContext + AnalyserNode and the playback scrubber from the stored audio URL. During recording, sample the analyser every 60ms and push amplitude values into a running JSON array that becomes the stored waveform_data.
- **Audio upload handler** (backend): Takes the assembled audio Blob, encodes it cross-browser using RecordRTC (npm: recordrtc) to ensure consistent WebM output, then uploads to the Supabase Storage voice-notes bucket. Returns a signed URL (1-hour expiry) for immediate playback and inserts a voice_notes row with storage_path and duration_seconds.
- **Audio player component** (ui): A custom HTML5 audio element wrapper that uses WaveSurfer.js to render the stored waveform_data array as a scrubable bar. Exposes playback rate buttons at 0.5×, 1×, 1.5×, and 2×. Fetches a fresh signed URL from Supabase before each play to avoid 403 errors on expired URLs.
- **Transcription service** (service): A Supabase Edge Function triggered after successful upload calls Deepgram Nova-2 (or OpenAI Whisper) with the audio file path. The transcript is written back to voice_notes.transcript. The UI shows 'Transcript loading...' until the column is populated.
- **Sharing logic** (data): The voice_notes table row controls access: is_public (bool) exposes the note to anyone with the link; shared_with (uuid[]) restricts to specific user IDs. Supabase RLS policies enforce both modes using auth.uid() checks and GIN index lookups on the array column.

## Data model

Two objects need persistence: the voice note metadata row and the audio file in Storage. Run this SQL in the Supabase SQL Editor to create the table, RLS policies, and index:

```sql
create table public.voice_notes (
  id uuid primary key default gen_random_uuid(),
  creator_id uuid references auth.users(id) on delete cascade not null,
  storage_path text not null,
  public_url text,
  duration_seconds int not null default 0,
  waveform_data jsonb,
  transcript text,
  is_public bool not null default false,
  shared_with uuid[] not null default '{}',
  created_at timestamptz not null default now()
);

alter table public.voice_notes enable row level security;

-- Creator can always read their own notes
create policy "Creators can read own voice notes"
  on public.voice_notes for select
  using (auth.uid() = creator_id);

-- Public notes are readable by anyone authenticated
create policy "Authenticated users can read public voice notes"
  on public.voice_notes for select
  using (is_public = true and auth.uid() is not null);

-- Shared-with users can read their notes
create policy "Shared users can read voice notes"
  on public.voice_notes for select
  using (auth.uid() = any(shared_with));

-- Only creator can insert
create policy "Creators can insert voice notes"
  on public.voice_notes for insert
  with check (auth.uid() = creator_id);

-- Only creator can update (e.g. transcript backfill)
create policy "Creators can update own voice notes"
  on public.voice_notes for update
  using (auth.uid() = creator_id);

-- Only creator can delete
create policy "Creators can delete own voice notes"
  on public.voice_notes for delete
  using (auth.uid() = creator_id);

create index voice_notes_creator_created_idx
  on public.voice_notes (creator_id, created_at desc);

-- GIN index for shared_with array lookups
create index voice_notes_shared_with_gin_idx
  on public.voice_notes using gin (shared_with);
```

Create a Supabase Storage bucket named 'voice-notes' with private visibility. The GIN index on shared_with keeps per-user inbox queries fast as the notes array grows.

## Build paths

### Lovable — fit 3/10, 6–9 hours

Lovable scaffolds the UI and Supabase Storage upload well, but the MediaRecorder permission flow and WaveSurfer.js integration require explicit, detailed prompting — expect 2–3 prompt iterations.

1. Create a new Lovable project and connect Lovable Cloud so Supabase Auth and Storage are available
2. Open Cloud tab → Secrets and add your DEEPGRAM_API_KEY (or OPENAI_API_KEY for Whisper)
3. Paste the prompt below in Agent Mode; let it build the recorder component, Supabase upload logic, and the player card
4. After the build, click Publish → Update — mic access and WaveSurfer.js both require the live HTTPS URL, not the preview iframe
5. Test recording on the published URL: grant mic permission, record 10 seconds, verify the waveform appears and the transcript populates within 30 seconds

Starter prompt:

```
Build a voice note sharing feature. Use the browser MediaRecorder API (navigator.mediaDevices.getUserMedia) for recording and the wavesurfer.js npm package for waveform visualization — import it with dynamic import and ssr:false to avoid SSR crashes. Recording UI: a circular Record button that turns red while active, a live waveform canvas updating every 60ms via AudioContext AnalyserNode, a countdown timer from 2:00 to 0:00, and a Stop button. On stop: assemble the audio chunks into a Blob, detect MIME type with MediaRecorder.isTypeSupported('audio/webm') and fall back to 'audio/mp4' for Safari, upload the blob to Supabase Storage bucket 'voice-notes' as {user_id}/{uuid}.webm, store the amplitude sample array as waveform_data JSON, then insert a row into voice_notes table (creator_id, storage_path, duration_seconds, waveform_data, is_public=false). After upload succeeds, call a Supabase Edge Function 'transcribe-voice-note' passing the storage_path; the Edge Function calls Deepgram Nova-2 async endpoint and writes the transcript back to voice_notes.transcript. Playback card: render waveform_data as a WaveSurfer scrub bar, show duration, speed toggle at 0.5x/1x/1.5x/2x, and transcript below with 'Transcript loading...' placeholder. Handle mic permission denial with an explainer state and a 'Try again' button. Disable the Share button until storage_path is confirmed set.
```

Limitations:

- Mic access is blocked in the Lovable preview iframe — all testing must happen on the published HTTPS URL
- AI sometimes generates deprecated webkitGetUserMedia instead of navigator.mediaDevices.getUserMedia — check the generated code and correct if needed
- The transcription Edge Function setup (Deepgram async webhook) is complex enough that a second dedicated prompt session is often needed

### V0 — fit 4/10, 6–8 hours

V0's TypeScript output handles the MediaRecorder state machine and Supabase upload pattern cleanly; Vercel deployment has no audio processing restrictions. Best path when voice notes are part of a larger Next.js app.

1. Prompt V0 with the spec below to generate the VoiceRecorder and VoiceNotePlayer components
2. Add environment variables in the Vars panel: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, DEEPGRAM_API_KEY
3. Run the SQL schema from this page in the Supabase SQL Editor and create the 'voice-notes' Storage bucket (private)
4. Deploy to Vercel; camera and mic access require HTTPS so the Vercel preview URL is sufficient for testing
5. Test the full flow: record, upload, confirm the transcript appears, then test sharing with a second browser session

Starter prompt:

```
Create a voice note sharing feature for a Next.js 14 App Router project. Component 1 — VoiceRecorder (client component): use navigator.mediaDevices.getUserMedia for mic access. Detect MIME type with MediaRecorder.isTypeSupported('audio/webm') and fall back to 'audio/mp4'. Accumulate ondataavailable chunks (timeslice: 250ms). Show a live waveform via AudioContext AnalyserNode sampling every 60ms into an amplitude array. Show countdown from 2:00. On stop, assemble chunks into a Blob, upload to Supabase Storage bucket 'voice-notes' at path {userId}/{uuid}.webm using the Supabase JS client, then POST to /api/voice-notes with { storage_path, duration_seconds, waveform_data, is_public: false }. Component 2 — VoiceNotePlayer (client component): accept a voice_notes row prop. Lazy-load wavesurfer.js with dynamic import ssr:false. Render waveform_data as a WaveSurfer instance. Speed buttons: 0.5x 1x 1.5x 2x. Show transcript with 'Transcript loading...' fallback. API route /api/voice-notes (POST): insert to Supabase voice_notes table and invoke Edge Function 'transcribe-voice-note'. API route /api/voice-notes/signed-url (GET): return a 1-hour signed URL for the given storage_path. Handle mic denied error with a friendly UI state. Disable share button until upload is confirmed.
```

Limitations:

- WaveSurfer.js SSR crash must be handled with dynamic import — V0 sometimes generates a static import that breaks the build
- The Deepgram transcription Edge Function is not auto-generated; you will need a second prompt or manual setup in Supabase Dashboard
- No automatic Supabase provisioning — you set up the table, RLS, and Storage bucket manually using the SQL from this page

### Custom — fit 5/10, 1–2 weeks

Full control over audio codec selection, server-side waveform pre-computation, FFmpeg re-encoding for size optimization, and live voice broadcasting. The right choice when audio quality and scalability are non-negotiable.

1. Implement proper Opus/WebM encoding via MediaRecorder with explicit codec constraints for cross-browser consistency
2. Add server-side waveform computation at upload time using audiowaveform or Web Audio API in a Node.js worker — stored JSON renders instantly client-side without WaveSurfer needing the raw audio
3. Add FFmpeg-based audio normalization and compression (reducing a 2-min WebM from 20MB to 3–5MB) via a Supabase Edge Function or Cloudflare Worker
4. Implement voice note expiry: a Supabase scheduled function deletes storage objects and rows older than the configured TTL (e.g. 7 days for ephemeral notes)

Limitations:

- Requires understanding of Web Audio API, streaming uploads, and server-side audio processing
- FFmpeg in a serverless function requires a custom layer or Cloudflare Worker with Wasm FFmpeg — not trivial to set up

## Gotchas

- **Mic access blocked in Lovable preview** — Lovable's preview pane runs inside a sandboxed cross-origin iframe that does not pass the allow='microphone' attribute. Any call to getUserMedia() returns a NotAllowedError immediately, making it appear the feature is broken when it is actually fine. Fix: Test voice recording exclusively on the published HTTPS URL. Click Publish → Update in Lovable and open the live domain on your phone or in a new browser tab. Never judge mic functionality from the preview iframe.
- **Safari records MP4 instead of WebM** — Safari's MediaRecorder implementation produces MP4/AAC audio. If your code hardcodes 'audio/webm' as the MIME type, Safari throws an error before recording starts. Meanwhile, some WaveSurfer.js configurations expect WebM and render the waveform incorrectly for MP4 streams. Fix: Detect support before constructing the recorder: const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'. Pass the detected mimeType to both the MediaRecorder constructor and WaveSurfer's format option.
- **WaveSurfer.js crashes Next.js on server render** — WaveSurfer.js accesses the browser document object on module import. Next.js attempts to import and render all components server-side during the build, which throws 'document is not defined' and crashes the build or produces a blank page. Fix: Load WaveSurfer exclusively on the client: const WaveSurfer = dynamic(() => import('wavesurfer.js'), { ssr: false }) in every file that uses it. This is not optional — it will break every V0-generated Next.js file where WaveSurfer is imported at the top level.
- **Upload fails silently for 2-minute recordings** — A 2-minute voice note at typical MediaRecorder quality produces a 15–25MB Blob stored in browser memory. Low-RAM mobile devices (under 2GB RAM) may silently drop the Blob before the upload starts, or the Supabase JS client upload times out on slow connections. Fix: Switch to chunked upload: call MediaRecorder with timeslice: 250 to receive 250ms chunks via ondataavailable, accumulate them in a chunks array, and only assemble into a final Blob after stop fires. For very long notes, stream chunks directly to Supabase Storage using multipart upload.
- **Transcription Edge Function times out on long audio** — Supabase Edge Functions have a hard 150-second execution limit. Synchronous Deepgram transcription for a 2-minute audio file takes 5–20 seconds plus network time, which can exceed the limit under cold-start conditions or slow network paths. Fix: Use Deepgram's async transcription endpoint: POST the request and receive a job ID, then poll or receive a webhook callback with the transcript. Store the transcript via the webhook to voice_notes.transcript when ready. The UI shows 'Transcript loading...' with a polling useEffect until the column is populated.

## Best practices

- Always detect MIME type support before constructing MediaRecorder — never hardcode 'audio/webm' or you will break Safari
- Store waveform amplitude data as a JSON array during recording so playback renders instantly without WaveSurfer needing to decode the full audio file
- Use async Deepgram transcription with a webhook callback rather than synchronous calls to avoid Edge Function timeouts on longer notes
- Generate signed URLs at playback time with a 1-hour expiry rather than storing permanent URLs — this keeps your Storage bucket private and enforces RLS at the data layer
- Show a clear microphone permission explainer with a 'Try again' button rather than a generic browser error — permission denial is the most common first-run failure
- Prevent sharing until storage_path is confirmed written in the database — disabling the Share button during upload avoids sharing a note that failed to persist
- Cap recording at 2 minutes client-side with a visual countdown; longer recordings amplify every cost (storage, bandwidth, transcription) and rarely improve UX for a sharing feature

## Frequently asked questions

### How do I record audio in the browser without a library?

The browser MediaRecorder API is built into every modern browser — no library needed to record. Call navigator.mediaDevices.getUserMedia({ audio: true }) to get a mic stream, pass it to new MediaRecorder(stream), and listen to ondataavailable for audio chunks. A library like RecordRTC is only needed if you want consistent cross-browser codec output (it normalizes WebM vs MP4 differences).

### How do I show a live waveform while recording?

Connect an AudioContext and AnalyserNode to the getUserMedia stream before passing it to MediaRecorder. Call analyser.getByteTimeDomainData(dataArray) every 60ms in a requestAnimationFrame loop and draw the amplitude values to a Canvas element. WaveSurfer.js wraps this pattern but adds SSR complexity in Next.js — you can also implement the Canvas drawing loop directly if you want to avoid the dynamic import requirement.

### How do I transcribe voice notes automatically?

After the audio file is uploaded to Supabase Storage, invoke a Supabase Edge Function that sends the file URL to Deepgram's Nova-2 API or OpenAI Whisper. For notes under 60 seconds, synchronous Deepgram calls work fine. For notes approaching 2 minutes, use Deepgram's async endpoint: submit the job and receive a webhook when the transcript is ready, then write it to voice_notes.transcript.

### Why does mic recording work on localhost but not my deployed app?

getUserMedia requires a secure context — HTTPS or localhost. Any http:// deployment blocks it silently. Additionally, Lovable's preview pane and V0's sandbox both run inside cross-origin iframes that don't grant microphone permissions regardless of HTTPS status. Always test on the actual deployed HTTPS URL, not inside any tool's preview.

### How do I handle Safari's different audio format?

Safari's MediaRecorder produces MP4/AAC instead of WebM. Check at runtime with MediaRecorder.isTypeSupported('audio/webm') before constructing the recorder. If it returns false (Safari), use 'audio/mp4'. Store the detected MIME type in your voice_notes row so the player can inform WaveSurfer of the correct format. Deepgram accepts both formats, so transcription is unaffected.

### How do I add playback speed controls to an audio player?

If you're using a raw HTML audio element, set audioElement.playbackRate to 0.5, 1, 1.5, or 2. If you're using WaveSurfer.js, call wavesurfer.setPlaybackRate(rate). Render four buttons and update state on click. Speed controls are supported in all modern browsers with no additional library.

### How much does audio transcription cost at scale?

Deepgram Nova-2 costs $0.0043/minute of audio (approx). At 1,000 users each sending 10 one-minute voice notes per month, that is 10,000 minutes at roughly $43/month. OpenAI Whisper costs $0.006/minute (approx) — about 40% more. Both offer batch processing discounts at high volume. Caching transcripts in your database means each note is only transcribed once regardless of how many times it is played.

### Can I delete voice notes automatically after a set number of days?

Yes. Create a Supabase scheduled function (pg_cron) that runs nightly: SELECT id, storage_path FROM voice_notes WHERE created_at < NOW() - INTERVAL '7 days'. For each row, call the Supabase Storage API to delete the file, then delete the row. You can also set a Supabase Storage lifecycle rule at the bucket level for automatic object expiry without custom code.

---

Source: https://www.rapidevelopers.com/app-features/voice-note-sharing
© RapidDev — https://www.rapidevelopers.com/app-features/voice-note-sharing
