ffmpeg-whisper-transcode.mjs
127 lines 3.2 KB
Raw
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