ffmpeg-whisper-transcode.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Transcode oversized media to a smaller M4A/AAC file for OpenAI Whisper (25 MB limit). |
| 3 | * Requires ffmpeg on PATH or FFMPEG_PATH. Used only when input exceeds WHISPER_MAX_FILE_BYTES. |
| 4 | */ |
| 5 | |
| 6 | import { spawn } from 'child_process'; |
| 7 | import fs from 'fs'; |
| 8 | import os from 'os'; |
| 9 | import path from 'path'; |
| 10 | import { randomBytes } from 'crypto'; |
| 11 | |
| 12 | const FFMPEG_TIMEOUT_MS = 10 * 60 * 1000; |
| 13 | |
| 14 | export function resolveFfmpegBinary() { |
| 15 | const fromEnv = process.env.FFMPEG_PATH; |
| 16 | if (fromEnv && String(fromEnv).trim() && fs.existsSync(fromEnv)) { |
| 17 | return path.resolve(fromEnv); |
| 18 | } |
| 19 | return 'ffmpeg'; |
| 20 | } |
| 21 | |
| 22 | export function probeFfmpegAvailable(bin) { |
| 23 | return new Promise((resolve) => { |
| 24 | const child = spawn(bin, ['-hide_banner', '-loglevel', 'error', '-version'], { |
| 25 | stdio: ['ignore', 'ignore', 'ignore'], |
| 26 | }); |
| 27 | child.on('error', () => resolve(false)); |
| 28 | child.on('close', (code) => resolve(code === 0)); |
| 29 | }); |
| 30 | } |
| 31 | |
| 32 | function runFfmpeg(bin, inputAbs, outputAbs, bitrate) { |
| 33 | return new Promise((resolve, reject) => { |
| 34 | const args = [ |
| 35 | '-hide_banner', |
| 36 | '-loglevel', |
| 37 | 'error', |
| 38 | '-nostdin', |
| 39 | '-y', |
| 40 | '-i', |
| 41 | inputAbs, |
| 42 | '-vn', |
| 43 | '-map_metadata', |
| 44 | '-1', |
| 45 | '-c:a', |
| 46 | 'aac', |
| 47 | '-b:a', |
| 48 | bitrate, |
| 49 | '-ac', |
| 50 | '1', |
| 51 | '-ar', |
| 52 | '16000', |
| 53 | outputAbs, |
| 54 | ]; |
| 55 | const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] }); |
| 56 | let errBuf = ''; |
| 57 | child.stderr?.on('data', (c) => { |
| 58 | errBuf += c.toString(); |
| 59 | }); |
| 60 | const timer = setTimeout(() => { |
| 61 | try { |
| 62 | child.kill('SIGKILL'); |
| 63 | } catch (_) {} |
| 64 | reject(new Error(`ffmpeg timed out after ${FFMPEG_TIMEOUT_MS / 1000}s`)); |
| 65 | }, FFMPEG_TIMEOUT_MS); |
| 66 | |
| 67 | const finish = (err) => { |
| 68 | clearTimeout(timer); |
| 69 | if (err) reject(err); |
| 70 | else resolve(); |
| 71 | }; |
| 72 | |
| 73 | child.on('error', (e) => finish(e)); |
| 74 | child.on('close', (code) => { |
| 75 | if (code === 0) finish(); |
| 76 | else { |
| 77 | const msg = errBuf.trim() || `exit code ${code}`; |
| 78 | finish(new Error(`ffmpeg failed: ${msg.slice(0, 500)}`)); |
| 79 | } |
| 80 | }); |
| 81 | }); |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * @param {string} inputAbs |
| 86 | * @param {number} maxBytes |
| 87 | * @returns {Promise<{ path: string, cleanup: () => void } | null>} |
| 88 | */ |
| 89 | export async function transcodeUnderWhisperLimit(inputAbs, maxBytes) { |
| 90 | const bin = resolveFfmpegBinary(); |
| 91 | const available = await probeFfmpegAvailable(bin); |
| 92 | if (!available) return null; |
| 93 | |
| 94 | const base = path.join(os.tmpdir(), `kn-whisper-${randomBytes(8).toString('hex')}`); |
| 95 | const bitrates = ['64k', '48k', '32k', '24k']; |
| 96 | let lastErr = null; |
| 97 | |
| 98 | for (const br of bitrates) { |
| 99 | const out = `${base}-aac-${br}.m4a`; |
| 100 | try { |
| 101 | await runFfmpeg(bin, inputAbs, out, br); |
| 102 | } catch (e) { |
| 103 | lastErr = e; |
| 104 | try { |
| 105 | if (fs.existsSync(out)) fs.unlinkSync(out); |
| 106 | } catch (_) {} |
| 107 | continue; |
| 108 | } |
| 109 | const st = fs.statSync(out); |
| 110 | if (st.size > 0 && st.size <= maxBytes) { |
| 111 | const pathOk = out; |
| 112 | return { |
| 113 | path: pathOk, |
| 114 | cleanup: () => { |
| 115 | try { |
| 116 | if (fs.existsSync(pathOk)) fs.unlinkSync(pathOk); |
| 117 | } catch (_) {} |
| 118 | }, |
| 119 | }; |
| 120 | } |
| 121 | try { |
| 122 | fs.unlinkSync(out); |
| 123 | } catch (_) {} |
| 124 | } |
| 125 | |
| 126 | throw lastErr || new Error('Could not compress audio under the 25MB Whisper limit'); |
| 127 | } |
File History
2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6
docs: accept Calendar Events v0 spec with Phase 0 security …
Human
1 day ago