'use strict'; /* * aac-encode.js — the ONE ADTS AAC encoder for the whole app. * * ADTS, not MP4: every frame carries its own header and length, which is what * lets live-audio.js splice sentences from different clips into one continuous * stream a phone can play with its screen locked. Do not "improve" this to an * .m4a — its moov box describes the whole file up front and cannot be spliced. * * macOS uses /usr/bin/afconvert, which ships with the OS and costs the app * nothing. Windows has no such tool, so it uses a bundled LGPL ffmpeg. That is * the whole platform difference, and it lives here rather than at the five * call sites — with one exception that callers DO have to read from here: * the two encoders prepend different amounts of priming (see PRIMING_FRAMES). */ const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); const WIN = process.platform === 'win32'; /* * Finding ffmpeg from a FORKED CHILD. server.js and tts-service.js are plain * node processes, so `process.resourcesPath` — an Electron-only property — * is undefined in them. main.js puts the resolved path in the environment * before forking; the other candidates are for running a child standalone. */ let ffmpegPath = null; function ffmpeg() { if (ffmpegPath) return ffmpegPath; const candidates = [ process.env.CT_FFMPEG, path.join(process.resourcesPath || '', 'ffmpeg.exe'), path.join(__dirname, '..', 'vendor-win', 'ffmpeg.exe') ].filter(Boolean); for (const c of candidates) { try { if (fs.existsSync(c) && fs.statSync(c).isFile()) { ffmpegPath = c; return c; } } catch (_) {} } throw new Error('ffmpeg was not found — it is what encodes audio for phones.'); } /* * Samples of encoder priming, in 1024-sample AAC frames, that every clip * carries at its front and the stream must drop. * * ADTS has no edit list to declare priming, so a decoder plays it as dead * silence at every sentence join. MEASURED by cross-correlating the decoded * clip against its source WAV at 16000 / 22050 / 24000 Hz on real speech: * * afconvert 2112 samples -> strip 2 frames (64 samples remain; the * debt ledger in pushContinuous eats them) * ffmpeg 1024 samples -> strip 1 frame (exactly one, no remainder) * * Getting it wrong costs real words: ffmpeg stripped at 2 keeps only 77% of * the onset energy — the same damage the old comment warns about for * afconvert at 3. The +64/chunk sample-debt arithmetic is IDENTICAL under both * encoders (verified: 11264 samples emitted for 11200 in, either way), so it * needs no platform branch. Do not change without re-measuring. */ const PRIMING_FRAMES = WIN ? 1 : 2; /* * Encode a mono 16-bit WAV to ADTS AAC at a PINNED sample rate. * * Pinned because a language's HLS stream carries one rate for its whole life; * a clip at another rate makes the stream rebuild and dumps everything queued. * 48 kbps everywhere — at 32 kbps afconvert silently resamples the input. * `rate` may be omitted to keep the WAV's own rate (the silence generator). */ function encodeAdts(wavPath, outPath, rate) { if (WIN) { const args = ['-hide_banner', '-loglevel', 'error', '-nostdin', '-y', '-i', wavPath, '-f', 'adts', '-c:a', 'aac', '-b:a', '48k', '-ac', '1']; if (rate) args.push('-ar', String(rate)); args.push('-map_metadata', '-1', outPath); execFileSync(ffmpeg(), args, { windowsHide: true }); } else { execFileSync('/usr/bin/afconvert', ['-f', 'adts', '-d', rate ? ('aac@' + rate) : 'aac', '-b', '48000', wavPath, outPath]); } } module.exports = { encodeAdts, PRIMING_FRAMES };