refactor(inbox): record voice notes client-side, drop server ffmpeg

Follow-up to #259. Voice notes are encoded to Ogg/Opus in the browser via
opus-recorder (small WASM encoder, lazy-loaded on record), so there's no
server-side transcode and nothing to provision on the host. The server
stores the .ogg like any other attachment; WhatsApp renders it as a voice
note.

- Composer: MediaRecorder + /api/whatsapp/transcode-audio replaced with
  opus-recorder. Encoder worker vendored at public/opus/encoderWorker.min.js
  (no install-time download to fail), ignored by eslint + prettier.
- Removed the transcode route, the audio-transcode lib + tests, the
  ffmpeg-static dep + ambient types, the FFMPEG_PATH env doc, and the
  unused transcodeAudio rate limit.
- Added opus-recorder ambient types.

Still behind the 'chat_media' beta flag with per-kind size caps.
typecheck/lint/build green; 414 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-18 11:27:12 +03:00
parent f35ae4ef1c
commit 4fa29b7a45
No known key found for this signature in database
13 changed files with 87 additions and 500 deletions

View File

@ -83,9 +83,3 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com
# Set this in CI and local development so you can exercise the full # Set this in CI and local development so you can exercise the full
# template UI without a real WABA. Leave unset (or "false") in prod. # template UI without a real WABA. Leave unset (or "false") in prod.
# WHATSAPP_TEMPLATES_DRY_RUN=true # WHATSAPP_TEMPLATES_DRY_RUN=true
# Override the ffmpeg binary used by POST /api/whatsapp/transcode-audio
# (voice notes are remuxed to OGG/Opus before being sent to Meta). By
# default the bundled `ffmpeg-static` binary is used; set this only if
# that binary can't run on your host and you have a system ffmpeg.
# FFMPEG_PATH=/usr/bin/ffmpeg

View File

@ -7,3 +7,4 @@ next-env.d.ts
package-lock.json package-lock.json
# Generated / vendored # Generated / vendored
supabase/migrations supabase/migrations
public/opus

View File

@ -12,6 +12,8 @@ const eslintConfig = defineConfig([
"out/**", "out/**",
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",
// Vendored minified opus-recorder encoder worker (served statically).
"public/opus/**",
]), ]),
]); ]);

169
package-lock.json generated
View File

@ -20,9 +20,9 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"ffmpeg-static": "^5.3.0",
"lucide-react": "^1.18.0", "lucide-react": "^1.18.0",
"next": "16.2.6", "next": "16.2.6",
"opus-recorder": "^8.0.5",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"recharts": "^3.8.1", "recharts": "^3.8.1",
@ -550,21 +550,6 @@
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==", "integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@derhuerst/http-basic": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
"license": "MIT",
"dependencies": {
"caseless": "^0.12.0",
"concat-stream": "^2.0.0",
"http-response-object": "^3.0.1",
"parse-cache-control": "^1.0.1"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@dnd-kit/accessibility": { "node_modules/@dnd-kit/accessibility": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
@ -4222,12 +4207,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/bundle-name": { "node_modules/bundle-name": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@ -4329,12 +4308,6 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0"
},
"node_modules/chai": { "node_modules/chai": {
"version": "6.2.2", "version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@ -4464,21 +4437,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@ -6020,47 +5978,6 @@
"node": "^12.20 || >= 14.13" "node": "^12.20 || >= 14.13"
} }
}, },
"node_modules/ffmpeg-static": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
"@derhuerst/http-basic": "^8.2.0",
"env-paths": "^2.2.0",
"https-proxy-agent": "^5.0.0",
"progress": "^2.0.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/ffmpeg-static/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ffmpeg-static/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/figures": { "node_modules/figures": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
@ -6607,21 +6524,6 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/http-response-object": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz",
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
"license": "MIT",
"dependencies": {
"@types/node": "^10.0.3"
}
},
"node_modules/http-response-object/node_modules/@types/node": {
"version": "10.17.60",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
"license": "MIT"
},
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@ -8436,6 +8338,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/opus-recorder": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/opus-recorder/-/opus-recorder-8.0.5.tgz",
"integrity": "sha512-tBRXc9Btds7i3bVfA7d5rekAlyOcfsivt5vSIXHxRV1Oa+s6iXFW8omZ0Lm3ABWotVcEyKt96iIIUcgbV07YOw==",
"license": "MIT"
},
"node_modules/ora": { "node_modules/ora": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz",
@ -8533,11 +8441,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/parse-cache-control": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
},
"node_modules/parse-json": { "node_modules/parse-json": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
@ -8826,15 +8729,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/prompts": { "node_modules/prompts": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@ -9001,20 +8895,6 @@
} }
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/recast": { "node_modules/recast": {
"version": "0.23.11", "version": "0.23.11",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
@ -9325,26 +9205,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-push-apply": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@ -9856,15 +9716,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
@ -10418,12 +10269,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",

View File

@ -50,9 +50,9 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"ffmpeg-static": "^5.3.0",
"lucide-react": "^1.18.0", "lucide-react": "^1.18.0",
"next": "16.2.6", "next": "16.2.6",
"opus-recorder": "^8.0.5",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"recharts": "^3.8.1", "recharts": "^3.8.1",

1
public/opus/encoderWorker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,148 +0,0 @@
import { NextResponse } from 'next/server'
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import ffmpegStatic from 'ffmpeg-static'
import { createClient } from '@/lib/supabase/server'
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit'
import { buildFfmpegArgs } from '@/lib/whatsapp/audio-transcode'
import { hasBetaFeature, CHAT_MEDIA_BETA } from '@/lib/auth/beta-features'
// ffmpeg + temp-file I/O need the Node runtime (not Edge).
export const runtime = 'nodejs'
// Recorded clips are short voice notes — same 16 MB ceiling as the
// chat-media bucket so we reject anything that couldn't be stored anyway.
const MAX_INPUT_BYTES = 16 * 1024 * 1024
/**
* Resolve the ffmpeg binary path. `ffmpeg-static` ships a per-platform
* binary and exports its absolute path; `FFMPEG_PATH` overrides it for
* hosts where the bundled binary can't run (e.g. a system ffmpeg).
*/
function resolveFfmpegPath(): string | null {
return process.env.FFMPEG_PATH || ffmpegStatic || null
}
function runFfmpeg(binary: string, args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
execFile(binary, args, { timeout: 30_000 }, (error, _stdout, stderr) => {
if (error) {
reject(new Error(stderr?.toString().slice(-500) || error.message))
return
}
resolve()
})
})
}
/**
* POST /api/whatsapp/transcode-audio
*
* Accepts a recorded audio clip (multipart form field `file`, typically
* Chromium's WebM/Opus) and returns OGG/Opus bytes that Meta accepts and
* WhatsApp renders as a playable voice note. The client only calls this
* when its recording isn't already a Meta-accepted format — Firefox
* records OGG and Safari records MP4/AAC, which skip the round-trip.
*
* The route does NOT upload anywhere: it returns the bytes and the client
* uploads them to the account-scoped chat-media bucket (same RLS path as
* every other attachment).
*/
export async function POST(request: Request) {
try {
const supabase = await createClient()
const {
data: { user },
error: authError,
} = await supabase.auth.getUser()
if (authError || !user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const limit = checkRateLimit(`transcode:${user.id}`, RATE_LIMITS.transcodeAudio)
if (!limit.success) {
return rateLimitResponse(limit)
}
// Voice notes are part of the beta-gated media feature — only opted-in
// accounts may transcode (mirrors the send route + client gate).
const { data: profile } = await supabase
.from('profiles')
.select('beta_features')
.eq('user_id', user.id)
.maybeSingle()
if (!hasBetaFeature(profile?.beta_features, CHAT_MEDIA_BETA)) {
return NextResponse.json(
{ error: 'Voice notes are not enabled for your account.' },
{ status: 403 },
)
}
const ffmpegPath = resolveFfmpegPath()
if (!ffmpegPath) {
console.error('[transcode-audio] ffmpeg binary not found')
return NextResponse.json(
{ error: 'Audio transcoding is unavailable on this server.' },
{ status: 500 },
)
}
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) {
return NextResponse.json(
{ error: 'Expected a `file` form field.' },
{ status: 400 },
)
}
if (file.size === 0) {
return NextResponse.json({ error: 'Empty audio file.' }, { status: 400 })
}
if (file.size > MAX_INPUT_BYTES) {
return NextResponse.json(
{ error: 'Audio clip exceeds the 16 MB limit.' },
{ status: 400 },
)
}
// Stage input + output in a per-request temp dir so concurrent
// transcodes never collide, and clean it up no matter what.
const dir = await mkdtemp(join(tmpdir(), 'wacrm-audio-'))
const inputPath = join(dir, 'in')
const outputPath = join(dir, 'out.ogg')
try {
await writeFile(inputPath, Buffer.from(await file.arrayBuffer()))
await runFfmpeg(ffmpegPath, buildFfmpegArgs(inputPath, outputPath))
const ogg = await readFile(outputPath)
if (ogg.byteLength > MAX_INPUT_BYTES) {
return NextResponse.json(
{ error: 'Transcoded audio exceeds the 16 MB limit.' },
{ status: 400 },
)
}
return new Response(new Uint8Array(ogg), {
status: 200,
headers: {
'Content-Type': 'audio/ogg',
'Cache-Control': 'no-store',
},
})
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {})
}
} catch (error) {
const message =
error instanceof Error ? error.message : 'Audio transcoding failed'
console.error('[transcode-audio] failed:', message)
return NextResponse.json(
{ error: 'Failed to process the recording.' },
{ status: 500 },
)
}
}

View File

@ -108,14 +108,10 @@ function formatDuration(seconds: number): string {
return `${m}:${s.toString().padStart(2, "0")}`; return `${m}:${s.toString().padStart(2, "0")}`;
} }
/** A recording can skip the transcode round-trip only if it's already /** Worker that encodes mic input to Ogg/Opus entirely in the browser
* OGG/Opus — the one format WhatsApp reliably renders as a voice note * (vendored from opus-recorder into /public). Recording client-side in a
* with a waveform. Everything else (Chromium WebM, Safari MP4/AAC) is * Meta-accepted format means no server ffmpeg / transcode step. */
* remuxed server-side so the voice-note UX is consistent across const OPUS_ENCODER_PATH = "/opus/encoderWorker.min.js";
* browsers, not a plain audio attachment on some of them. */
function isOggOpus(mime: string): boolean {
return mime.split(";")[0].trim().toLowerCase() === "audio/ogg";
}
export function MessageComposer({ export function MessageComposer({
conversationId, conversationId,
@ -151,12 +147,11 @@ export function MessageComposer({
void deleteAccountMedia(CHAT_MEDIA_BUCKET, path).catch(() => {}); void deleteAccountMedia(CHAT_MEDIA_BUCKET, path).catch(() => {});
}, []); }, []);
// Voice recording state. // Voice recording state. The recorder encodes Ogg/Opus in-browser
// (opus-recorder) so there's no server-side transcode.
const [recording, setRecording] = useState(false); const [recording, setRecording] = useState(false);
const [recordSeconds, setRecordSeconds] = useState(0); const [recordSeconds, setRecordSeconds] = useState(0);
const recorderRef = useRef<MediaRecorder | null>(null); const recorderRef = useRef<import("opus-recorder").default | null>(null);
const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null);
const cancelledRef = useRef(false); const cancelledRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
@ -185,7 +180,9 @@ export function MessageComposer({
useEffect(() => { useEffect(() => {
return () => { return () => {
clearTimer(); clearTimer();
streamRef.current?.getTracks().forEach((t) => t.stop()); cancelledRef.current = true;
// stop() releases the mic stream + audio context inside opus-recorder.
void recorderRef.current?.stop().catch(() => {});
removeStaged(draftRef.current?.path); removeStaged(draftRef.current?.path);
}; };
}, [clearTimer, removeStaged]); }, [clearTimer, removeStaged]);
@ -269,41 +266,29 @@ export function MessageComposer({
[stageUpload], [stageUpload],
); );
// ---- Voice recording ---------------------------------------------- // ---- Voice recording (client-side Ogg/Opus, no server transcode) ---
// The encoded Ogg/Opus file from opus-recorder → upload as an audio
// draft. WhatsApp renders Ogg/Opus as a playable voice note.
const finalizeRecording = useCallback( const finalizeRecording = useCallback(
async (blob: Blob, mime: string) => { async (bytes: Uint8Array) => {
setBusy(true); // Uint8Array is a valid BlobPart at runtime; the cast sidesteps the
try { // lib.dom ArrayBufferLike-vs-ArrayBuffer generic mismatch.
let file: File; const file = new File([bytes as unknown as BlobPart], `voice-${Date.now()}.ogg`, {
if (isOggOpus(mime)) { type: "audio/ogg",
// Firefox records OGG/Opus directly — upload as-is.
file = new File([blob], `voice-${Date.now()}.ogg`, { type: "audio/ogg" });
} else {
// Chromium (WebM) and Safari (MP4) are remuxed to OGG/Opus so
// WhatsApp renders a voice note, not a plain audio attachment.
const form = new FormData();
form.append("file", blob, "recording");
const res = await fetch("/api/whatsapp/transcode-audio", {
method: "POST",
body: form,
}); });
if (!res.ok) { if (file.size === 0) return; // cancelled / empty take
const payload = await res.json().catch(() => ({}));
throw new Error(payload?.error || `Transcode failed (HTTP ${res.status})`);
}
const ogg = await res.blob();
file = new File([ogg], `voice-${Date.now()}.ogg`, { type: "audio/ogg" });
}
if (file.size > MEDIA_MAX_BYTES_BY_KIND.audio) { if (file.size > MEDIA_MAX_BYTES_BY_KIND.audio) {
toast.error("Recording is too long (over 16 MB)."); toast.error("Recording is too long (over 16 MB).");
return; return;
} }
setBusy(true);
try {
const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file); const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
removeStaged(draftRef.current?.path); removeStaged(draftRef.current?.path);
setDraft({ kind: "audio", mediaUrl: publicUrl, path, filename: file.name, caption: "" }); setDraft({ kind: "audio", mediaUrl: publicUrl, path, filename: file.name, caption: "" });
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : "Could not process the recording."); toast.error(err instanceof Error ? err.message : "Upload failed.");
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -313,41 +298,34 @@ export function MessageComposer({
const startRecording = useCallback(async () => { const startRecording = useCallback(async () => {
if (inputsDisabled || busy || recording) return; if (inputsDisabled || busy || recording) return;
if (typeof MediaRecorder === "undefined" || !navigator.mediaDevices?.getUserMedia) { if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === "undefined") {
toast.error("Voice recording isn't supported in this browser."); toast.error("Voice recording isn't supported in this browser.");
return; return;
} }
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // Lazy-load the encoder (≈400 KB worker) only when the user records,
streamRef.current = stream; // keeping it out of the main bundle.
// Prefer a Meta-accepted container (Firefox supports ogg/opus) so const { default: Recorder } = await import("opus-recorder");
// we can skip the transcode round-trip; Chromium falls back to webm. const recorder = new Recorder({
const preferred = ["audio/ogg;codecs=opus", "audio/webm;codecs=opus", "audio/webm"]; encoderPath: OPUS_ENCODER_PATH,
const mimeType = preferred.find((t) => MediaRecorder.isTypeSupported(t)) || ""; numberOfChannels: 1,
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); encoderApplication: 2048, // VOIP — tuned for speech
chunksRef.current = []; encoderSampleRate: 48000,
streamPages: false, // one callback with the complete file on stop
});
cancelledRef.current = false; cancelledRef.current = false;
recorder.ondataavailable = (e) => { recorder.ondataavailable = (bytes) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
stream.getTracks().forEach((t) => t.stop());
streamRef.current = null;
const chunks = chunksRef.current;
chunksRef.current = [];
if (cancelledRef.current) return; if (cancelledRef.current) return;
const type = recorder.mimeType || mimeType || "audio/webm"; void finalizeRecording(bytes);
const blob = new Blob(chunks, { type });
if (blob.size > 0) void finalizeRecording(blob, type);
}; };
recorderRef.current = recorder; recorderRef.current = recorder;
recorder.start(); await recorder.start();
setRecording(true); setRecording(true);
setRecordSeconds(0); setRecordSeconds(0);
timerRef.current = setInterval(() => setRecordSeconds((s) => s + 1), 1000); timerRef.current = setInterval(() => setRecordSeconds((s) => s + 1), 1000);
} catch { } catch {
streamRef.current?.getTracks().forEach((t) => t.stop()); void recorderRef.current?.stop().catch(() => {});
streamRef.current = null; recorderRef.current = null;
toast.error("Microphone access denied or unavailable."); toast.error("Microphone access denied or unavailable.");
} }
}, [inputsDisabled, busy, recording, finalizeRecording]); }, [inputsDisabled, busy, recording, finalizeRecording]);
@ -355,18 +333,18 @@ export function MessageComposer({
const stopRecording = useCallback(() => { const stopRecording = useCallback(() => {
clearTimer(); clearTimer();
setRecording(false); setRecording(false);
recorderRef.current?.stop(); void recorderRef.current?.stop().catch(() => {});
}, [clearTimer]); }, [clearTimer]);
const cancelRecording = useCallback(() => { const cancelRecording = useCallback(() => {
cancelledRef.current = true; cancelledRef.current = true;
clearTimer(); clearTimer();
setRecording(false); setRecording(false);
recorderRef.current?.stop(); void recorderRef.current?.stop().catch(() => {});
}, [clearTimer]); }, [clearTimer]);
// Auto-stop at the cap so a forgotten recording can't blow the // Auto-stop at the cap so a forgotten recording can't blow the
// upload/transcode limits. // upload size limit.
useEffect(() => { useEffect(() => {
if (recording && recordSeconds >= MAX_RECORDING_SECONDS) { if (recording && recordSeconds >= MAX_RECORDING_SECONDS) {
stopRecording(); stopRecording();

View File

@ -125,10 +125,6 @@ export const RATE_LIMITS = {
* fidget with reactions and a single "swap" is actually two calls * fidget with reactions and a single "swap" is actually two calls
* (remove + add) under the hood. */ * (remove + add) under the hood. */
react: { limit: 120, windowMs: 60_000 }, react: { limit: 120, windowMs: 60_000 },
/** Voice-note transcode (WebM/Opus → OGG/Opus). One call per recorded
* clip; tighter than send because each spawns an ffmpeg process. 20/min
* is far above any human recording cadence. */
transcodeAudio: { limit: 20, windowMs: 60_000 },
/** Invitation peek (public, per-IP). 30/min lets a forwarded link /** Invitation peek (public, per-IP). 30/min lets a forwarded link
* retry a handful of times under flaky connectivity without * retry a handful of times under flaky connectivity without
* enabling brute-force token enumeration. With 256-bit tokens the * enabling brute-force token enumeration. With 256-bit tokens the

View File

@ -1,43 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildFfmpegArgs,
isMetaAcceptedAudio,
META_ACCEPTED_AUDIO_MIME,
} from "./audio-transcode";
describe("isMetaAcceptedAudio", () => {
it("accepts the Meta-supported audio types", () => {
for (const mime of META_ACCEPTED_AUDIO_MIME) {
expect(isMetaAcceptedAudio(mime)).toBe(true);
}
});
it("strips the codec suffix before matching", () => {
expect(isMetaAcceptedAudio("audio/ogg;codecs=opus")).toBe(true);
expect(isMetaAcceptedAudio("audio/mp4; codecs=mp4a.40.2")).toBe(true);
});
it("rejects WebM (Chromium's MediaRecorder default) — needs transcode", () => {
expect(isMetaAcceptedAudio("audio/webm")).toBe(false);
expect(isMetaAcceptedAudio("audio/webm;codecs=opus")).toBe(false);
});
it("rejects empty / nullish input", () => {
expect(isMetaAcceptedAudio("")).toBe(false);
expect(isMetaAcceptedAudio(undefined)).toBe(false);
expect(isMetaAcceptedAudio(null)).toBe(false);
});
});
describe("buildFfmpegArgs", () => {
it("targets OGG/Opus mono and overwrites the output", () => {
const args = buildFfmpegArgs("/tmp/in", "/tmp/out.ogg");
expect(args).toContain("-y");
expect(args).toEqual(expect.arrayContaining(["-i", "/tmp/in"]));
expect(args).toEqual(expect.arrayContaining(["-c:a", "libopus"]));
expect(args).toEqual(expect.arrayContaining(["-f", "ogg"]));
expect(args).toEqual(expect.arrayContaining(["-ac", "1"]));
// Output path is last so ffmpeg treats it as the destination.
expect(args[args.length - 1]).toBe("/tmp/out.ogg");
});
});

View File

@ -1,63 +0,0 @@
/**
* Pure helpers for the voice-note transcode flow. Kept free of Node /
* ffmpeg imports so they can be unit-tested without spawning a process —
* the route handler (`/api/whatsapp/transcode-audio`) does the actual
* spawning.
*/
/**
* Audio MIME types Meta accepts for OUTBOUND messages on the WhatsApp
* Cloud API. Anything outside this set must be transcoded before send.
* Notably absent: `audio/webm` (Chromium's default MediaRecorder output)
* — which is exactly why the transcode step exists.
*
* https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media#supported-media-types
*/
export const META_ACCEPTED_AUDIO_MIME = new Set<string>([
"audio/aac",
"audio/mp4",
"audio/mpeg",
"audio/amr",
"audio/ogg", // Opus only — the codec MediaRecorder + ffmpeg produce here
]);
/**
* Whether a recorded clip's MIME type can be sent to Meta as-is. The
* client only round-trips through the transcode route when this is false.
* The codec suffix (e.g. `audio/ogg;codecs=opus`) is stripped before the
* lookup.
*/
export function isMetaAcceptedAudio(mimeType: string | undefined | null): boolean {
if (!mimeType) return false;
const base = mimeType.split(";")[0]!.trim().toLowerCase();
return META_ACCEPTED_AUDIO_MIME.has(base);
}
/**
* ffmpeg arguments to remux/encode an arbitrary recorded clip into
* OGG/Opus — the format WhatsApp renders as a playable voice note.
*
* We re-encode to libopus rather than `-c:a copy` so the output is valid
* regardless of the input container/codec (Chromium gives WebM/Opus,
* older browsers may give other codecs). Opus at 48k mono is the voice-
* note sweet spot and keeps the file tiny.
*/
export function buildFfmpegArgs(inputPath: string, outputPath: string): string[] {
return [
"-y", // overwrite output if it exists
"-i",
inputPath,
"-vn", // no video stream
"-c:a",
"libopus",
"-b:a",
"32k",
"-ar",
"48000",
"-ac",
"1",
"-f",
"ogg",
outputPath,
];
}

View File

@ -1,7 +0,0 @@
// `ffmpeg-static` ships no type declarations. Its default export is the
// absolute path to the bundled ffmpeg binary (or null if unavailable on
// the current platform).
declare module "ffmpeg-static" {
const ffmpegPath: string | null;
export default ffmpegPath;
}

31
src/types/opus-recorder.d.ts vendored Normal file
View File

@ -0,0 +1,31 @@
// `opus-recorder` ships no type declarations. We use a small subset of its
// API: construct a Recorder, start()/stop(), and receive the encoded
// Ogg/Opus file via `ondataavailable`. See https://github.com/chris-rudmin/opus-recorder
declare module "opus-recorder" {
interface RecorderConfig {
/** URL of the encoder worker (served from /public). */
encoderPath?: string;
/** Media track constraints, or `true` for default audio. */
mediaTrackConstraints?: MediaTrackConstraints | boolean;
/** 1 = mono, 2 = stereo. */
numberOfChannels?: number;
/** 2048 = Voice, 2049 = Full Band Audio, 2051 = Restricted Low Delay. */
encoderApplication?: number;
/** 8000 | 12000 | 16000 | 24000 | 48000. */
encoderSampleRate?: number;
/** Target bitrate in bits/sec. */
encoderBitRate?: number;
/** When false (default), ondataavailable fires once with the full file. */
streamPages?: boolean;
}
export default class Recorder {
constructor(config?: RecorderConfig);
/** Fired with the encoded audio bytes (full Ogg/Opus file when streamPages is false). */
ondataavailable: ((data: Uint8Array) => void) | null;
start(): Promise<void>;
stop(): Promise<void>;
/** Browser support probe exposed as a static on the class. */
static isRecordingSupported(): boolean;
}
}