Church Translate, as it runs at the campuses
Live sermon translation into 100+ languages, running entirely on one Mac per campus: audio in, voice detection, Whisper on the GPU, NLLB-200 in its own process, and a built-in web server the congregation reads from on their phones. No audio leaves the building. This is the source only. What used to sit beside it — 14 GB of speech and translation models, 8 GB of voice bundles, 6 GB of natural voices, the Electron caches, and the app data holding the operator PIN and every service transcript — is machine state and downloaded assets, and stays in the NAS backup where it belongs. The vendored node, cloudflared and Windows ffmpeg binaries are out too (533 MB, re-fetched for packaging); the browser-side runtime beside them is kept, so a checkout still serves the congregation page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user