mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
f35ae4ef1c
commit
4fa29b7a45
@ -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
|
|
||||||
|
|||||||
@ -7,3 +7,4 @@ next-env.d.ts
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
# Generated / vendored
|
# Generated / vendored
|
||||||
supabase/migrations
|
supabase/migrations
|
||||||
|
public/opus
|
||||||
|
|||||||
@ -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
169
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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
1
public/opus/encoderWorker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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) => {
|
||||||
|
// Uint8Array is a valid BlobPart at runtime; the cast sidesteps the
|
||||||
|
// lib.dom ArrayBufferLike-vs-ArrayBuffer generic mismatch.
|
||||||
|
const file = new File([bytes as unknown as BlobPart], `voice-${Date.now()}.ogg`, {
|
||||||
|
type: "audio/ogg",
|
||||||
|
});
|
||||||
|
if (file.size === 0) return; // cancelled / empty take
|
||||||
|
if (file.size > MEDIA_MAX_BYTES_BY_KIND.audio) {
|
||||||
|
toast.error("Recording is too long (over 16 MB).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
let file: File;
|
|
||||||
if (isOggOpus(mime)) {
|
|
||||||
// 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) {
|
|
||||||
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) {
|
|
||||||
toast.error("Recording is too long (over 16 MB).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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();
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
7
src/types/ffmpeg-static.d.ts
vendored
7
src/types/ffmpeg-static.d.ts
vendored
@ -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
31
src/types/opus-recorder.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user