Feature spec
AdvancedCategory
communication-social
Build with AI
6–10 hours with Lovable
Custom build
1–2 weeks custom dev
Running cost
$0–5/mo up to 100 users · $10–30/mo at 1K users
Works on
Everything it takes to ship Voice Note Sharing — parts, prompts, and real costs.
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.
What users consider table stakes in 2026
- One-tap record button with a live animated waveform during recording so users know the mic is working
- Max recording duration limit (2 minutes) with a visible countdown timer so recordings don't grow unbounded
- Inline playback with a scrub bar and speed controls (0.5×, 1×, 1.5×, 2×) directly on the note card
- Waveform thumbnail rendered on shared note cards so recipients see what they are about to play
- Auto-transcription shown below the audio player for accessibility and searchability
- Graceful mic permission denial handling with a clear 'microphone access required' explainer state
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
UIRequests 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.
Note: Requires HTTPS — getUserMedia silently fails on http:// and inside cross-origin iframes like Lovable's preview. Safari returns MP4/AAC instead of WebM; detect with MediaRecorder.isTypeSupported() before constructing the recorder.
Waveform visualizer
UIWaveSurfer.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.
Note: WaveSurfer.js accesses document on import, causing a crash in Next.js SSR. Always load it with dynamic(() => import('wavesurfer.js'), { ssr: false }).
Audio upload handler
BackendTakes 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.
Note: Chunked upload is necessary for longer recordings — a 2-minute high-quality WebM approaches 20MB which can exceed in-memory blob limits on low-RAM mobile devices.
Audio player component
UIA 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.
Note: Do not store the signed URL permanently — it expires after 1 hour by design. Re-request it from the backend on each mount.
Transcription service
ServiceA 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.
Note: Use Deepgram's async transcription endpoint — synchronous calls time out for notes over ~90 seconds because Supabase Edge Functions have a 150-second limit.
Sharing logic
DataThe 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.
Note: Storage bucket should be private — serve audio exclusively through signed URLs, never public bucket URLs, so RLS on the metadata table remains the single source of truth for access.
The 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:
1create table public.voice_notes (2 id uuid primary key default gen_random_uuid(),3 creator_id uuid references auth.users(id) on delete cascade not null,4 storage_path text not null,5 public_url text,6 duration_seconds int not null default 0,7 waveform_data jsonb,8 transcript text,9 is_public bool not null default false,10 shared_with uuid[] not null default '{}',11 created_at timestamptz not null default now()12);1314alter table public.voice_notes enable row level security;1516-- Creator can always read their own notes17create policy "Creators can read own voice notes"18 on public.voice_notes for select19 using (auth.uid() = creator_id);2021-- Public notes are readable by anyone authenticated22create policy "Authenticated users can read public voice notes"23 on public.voice_notes for select24 using (is_public = true and auth.uid() is not null);2526-- Shared-with users can read their notes27create policy "Shared users can read voice notes"28 on public.voice_notes for select29 using (auth.uid() = any(shared_with));3031-- Only creator can insert32create policy "Creators can insert voice notes"33 on public.voice_notes for insert34 with check (auth.uid() = creator_id);3536-- Only creator can update (e.g. transcript backfill)37create policy "Creators can update own voice notes"38 on public.voice_notes for update39 using (auth.uid() = creator_id);4041-- Only creator can delete42create policy "Creators can delete own voice notes"43 on public.voice_notes for delete44 using (auth.uid() = creator_id);4546create index voice_notes_creator_created_idx47 on public.voice_notes (creator_id, created_at desc);4849-- GIN index for shared_with array lookups50create index voice_notes_shared_with_gin_idx51 on public.voice_notes using gin (shared_with);Heads up: 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 it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
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.
Step by step
- 1Implement proper Opus/WebM encoding via MediaRecorder with explicit codec constraints for cross-browser consistency
- 2Add 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
- 3Add FFmpeg-based audio normalization and compression (reducing a 2-min WebM from 20MB to 3–5MB) via a Supabase Edge Function or Cloudflare Worker
- 4Implement voice note expiry: a Supabase scheduled function deletes storage objects and rows older than the configured TTL (e.g. 7 days for ephemeral notes)
Where this path bites
- 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
Third-party services you'll need
Recording and playback are free. Costs appear at transcription and storage scale:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase Storage | Hosts audio files; private bucket with signed URL access | 1GB storage included on free plan | $25/mo (Pro) + $0.021/GB over 100GB (approx) |
| Deepgram Nova-2 | Speech-to-text transcription via async API after upload | $200 credit on signup | $0.0043/min audio (approx) |
| OpenAI Whisper API | Transcription alternative; slightly more expensive but no signup credit | No free tier beyond initial API credit | $0.006/min audio (approx) |
| WaveSurfer.js | Waveform rendering during recording and playback | Free (open source) | $0 |
| RecordRTC | Cross-browser audio encoding to consistent WebM/MP4 output | Free (open source) | $0 |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase Storage free tier (1GB covers ~1,000 one-minute notes). Deepgram $200 signup credit absorbs all transcription at this scale — roughly $0.26/note at 1 minute average.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Mic access blocked in Lovable preview
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
When You Need Custom Development
AI tools handle the record-upload-transcribe loop well. These use cases exceed what a first-build prompt session can produce reliably:
- Audio compression is required server-side (FFmpeg re-encoding to Opus reduces file size 4–6× over raw WebM, cutting storage and bandwidth costs significantly at scale)
- Waveform data must be pre-computed at upload time on the server for instant rendering without shipping raw audio to every viewer
- Voice notes need automatic expiry (delete after 7 days) via a scheduled Supabase function with Storage object cleanup
- Live voice broadcasting to multiple simultaneous listeners is required — this needs a WebRTC SFU (Selective Forwarding Unit) like LiveKit, which is a fundamentally different architecture from recorded note sharing
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
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.
Need this feature production-ready?
RapidDev builds voice note sharing into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.