Files
church-translate/app/server/aac-encode.js
T

84 lines
3.6 KiB
JavaScript
Raw Normal View History

'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 };