Most voice messaging apps know who you are. Your name, your device, your IP, your contact list. Stealth Speak inverts this: the sender is completely anonymous, the audio sounds professional-grade, and the only thing either party shares is a link.
Here's how I built it in two days.
The Privacy Model First
Before writing a line of code, I worked backwards from the threat model: what would a bad actor need to identify the sender?
- IP address — common tracking vector. Solution: never log it in the handler.
- User-agent / device fingerprint — solution: never store it, reject it in the schema.
- File metadata — audio files contain
Recorded-By,Software, and sometimes GPS in metadata. Solution: strip all metadata before storage. - Upload timing — correlating upload time with known sender activity. Solution: add a jitter delay before processing (100–500ms random).
With that model defined, the schema became obvious: messages have a recipientToken, a processed audio URL, a creation timestamp, and nothing else.
// schema — Mongoose model
const MessageSchema = new mongoose.Schema({
recipientToken: { type: String, required: true, index: true },
audioUrl: { type: String, required: true },
duration: { type: Number, required: true },
createdAt: { type: Date, default: Date.now, expires: "7d" }, // TTL index
});
The expires: "7d" TTL index auto-deletes messages after seven days. No manual cleanup job, no forgotten records.
The Audio Pipeline
Every voice message passes through four stages before the recipient hears it:
Browser Recording → Upload to /api/messages → Google AI Enhancement → Cloudinary → Recipient
Stage 1: In-Browser Recording
The Web Audio API gave me everything I needed without a library:
// hooks/useVoiceRecorder.ts
export function useVoiceRecorder() {
const [stream, setStream] = useState<MediaStream | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const start = async () => {
const mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1, // Mono — enough for voice
sampleRate: 16000, // 16kHz — Google AI's preferred sample rate
echoCancellation: true,
noiseSuppression: true, // OS-level pre-processing
}
});
setStream(mediaStream);
const recorder = new MediaRecorder(mediaStream, {
mimeType: "audio/webm;codecs=opus",
});
recorder.ondataavailable = (e) => chunksRef.current.push(e.data);
recorder.start(250); // Collect chunks every 250ms
recorderRef.current = recorder;
};
const stop = (): Promise<Blob> => {
return new Promise((resolve) => {
recorderRef.current!.onstop = () => {
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
resolve(blob);
stream?.getTracks().forEach((t) => t.stop());
};
recorderRef.current!.stop();
});
};
return { start, stop };
}
Requesting mono at 16kHz serves two purposes: it matches Google AI's Speech-to-Audio API's optimal input format, and it reduces file size by roughly 60% compared to stereo at 44.1kHz — critical when you're processing every message through an API.
Stage 2: Metadata Stripping
The browser's MediaRecorder embeds metadata in the WebM container — browser name, sometimes OS version. Before the file goes anywhere, I strip it:
// lib/stripAudioMetadata.ts
import { execSync } from "child_process";
import { writeFileSync, readFileSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
export async function stripAudioMetadata(buffer: Buffer): Promise<Buffer> {
const inputPath = join(tmpdir(), `input-${Date.now()}.webm`);
const outputPath = join(tmpdir(), `output-${Date.now()}.webm`);
writeFileSync(inputPath, buffer);
// ffmpeg with -map_metadata -1 strips all metadata tags
execSync(
`ffmpeg -i ${inputPath} -map_metadata -1 -c:a copy ${outputPath} -y -loglevel quiet`
);
const cleaned = readFileSync(outputPath);
unlinkSync(inputPath);
unlinkSync(outputPath);
return cleaned;
}
Stage 3: Google AI Audio Enhancement
This is where Stealth Speak earns its name. A message recorded on a phone in a noisy room can sound remarkably clear after processing.
// lib/enhanceAudio.ts
import { GoogleGenerativeAI } from "@google/generative-ai";
const genAI = new GoogleGenerativeAI(process.env.GOOGLE_AI_API_KEY!);
export async function enhanceAudio(audioBuffer: Buffer): Promise<Buffer> {
const model = genAI.getGenerativeModel({ model: "gemini-1.5-flash" });
// Convert buffer to base64 for the API
const base64Audio = audioBuffer.toString("base64");
const result = await model.generateContent([
{
inlineData: {
mimeType: "audio/webm",
data: base64Audio,
},
},
{
text: `Process this audio to:
1. Reduce background noise and room reverb
2. Normalise volume to -14 LUFS (broadcast standard)
3. Enhance speech clarity and intelligibility
4. Remove any silence gaps longer than 500ms
Return only the processed audio, no transcript.`,
},
]);
// Extract audio from response
const audioData = result.response.candidates?.[0]?.content.parts
.find((p) => p.inlineData?.mimeType?.startsWith("audio/"))
?.inlineData?.data;
if (!audioData) throw new Error("No audio in Google AI response");
return Buffer.from(audioData, "base64");
}
Stage 4: Storage and Delivery
// app/api/messages/route.ts
import { NextRequest, NextResponse } from "next/server";
import { v2 as cloudinary } from "cloudinary";
export async function POST(req: NextRequest) {
const formData = await req.formData();
const audioFile = formData.get("audio") as File;
const recipientToken = formData.get("recipientToken") as string;
const rawBuffer = Buffer.from(await audioFile.arrayBuffer());
// Add jitter to prevent timing correlation
await new Promise((r) => setTimeout(r, 100 + Math.random() * 400));
const stripped = await stripAudioMetadata(rawBuffer);
const enhanced = await enhanceAudio(stripped);
// Upload to Cloudinary with a random, unguessable filename
const uploadResult = await cloudinary.uploader.upload_stream(
{
resource_type: "video", // Cloudinary uses 'video' for audio files
folder: "stealth-speak/messages",
public_id: crypto.randomUUID(), // Unguessable URL
format: "mp3",
},
/* ... stream handler ... */
);
// Save to MongoDB — no sender info stored
await Message.create({
recipientToken,
audioUrl: uploadResult.secure_url,
duration: await getAudioDuration(enhanced),
});
return NextResponse.json({ success: true });
}
Notice what's absent from the handler: no req.ip, no req.headers.get("user-agent"), no sender ID. The API receives audio, processes it, and stores it. The sender never exists in the database.
The Recipient Link System
Recipients generate their anonymous inbox via /api/tokens/generate:
// Deterministic but unguessable token
const token = crypto.randomBytes(32).toString("base64url");
// Stored as a bcrypt hash — even if the DB leaks, tokens are useless
const tokenHash = await bcrypt.hash(token, 10);
await RecipientToken.create({ hash: tokenHash, createdAt: new Date() });
// Return the raw token once — never stored in plaintext
return NextResponse.json({ token });
The link looks like: stealthspeak.rahulkapoor.dev/inbox/abc123def456...
When someone visits their inbox, the token is looked up by hash. Messages are only returned if the hash matches. The raw token is never persisted — even a full database dump reveals nothing about who owns which inbox.
The UX Detail That Matters
The recording page has a single state machine with four states: idle → recording → processing → done. Each state has a distinct visual treatment — the record button pulses red while recording, a progress ring fills during processing, and a waveform animation plays on the done state.
The one thing I consciously didn't add: a preview playback before sending. The temptation was to let senders hear their message before submitting. But previewing the processed audio requires processing it first, which adds latency and complexity. More importantly: the anonymity model is cleaner if messages are one-shot. Senders commit to the message. Recipients receive it as sent.
What I'd Build Next
Self-destructing messages. Currently messages expire in 7 days via MongoDB TTL. A per-message configurable expiry — or a "listen once and delete" mode — would be straightforward to add.
Recipient-side block list. Anonymity creates an abuse vector. A token-level block system (not tied to the sender, since we don't have sender identity, but based on message fingerprints) would help recipients filter unwanted messages.
End-to-end encryption. Currently messages are encrypted at rest by Cloudinary. A proper E2E scheme where the server can never decrypt the audio would make the privacy model complete.
The Lesson
Building for privacy is mostly about discipline in what you choose not to store. Every piece of data you don't collect is data that can never leak, can never be subpoenaed, and can never be misused. Start from zero — add data only when you have a specific, justified reason for it.
The architecture of Stealth Speak is a direct consequence of the threat model I defined before writing a single line of code. Define your constraints first. The implementation follows naturally.