diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index d33792b..a9be4e5 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -20,10 +20,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Install sox and multimon-ng + - name: Install ffmpeg and multimon-ng run: | sudo apt-get update - sudo apt-get install -y sox multimon-ng + sudo apt-get install -y ffmpeg multimon-ng - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: diff --git a/README.md b/README.md index f90882d..7d365eb 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ Supports full and **partial** match detection — even if only the attention ton ## Requirements - Node.js >= 18 -- [SoX](https://sox.sourceforge.net/) -- [multimon-ng](https://github.com/EliasOenal/multimon-ng) +- [FFmpeg](https://ffmpeg.org/) (`brew install ffmpeg`) +- [multimon-ng](https://github.com/EliasOenal/multimon-ng) (build from source or `apt install multimon-ng`) ## Install @@ -20,7 +20,7 @@ npm install @prx.org/eas-detect ## CLI Usage -Call on any audio file, it will use sox to analyze the file: +Call on any audio file, it will use ffmpeg to analyze the file: ```bash npx eas-detect recording.wav @@ -177,7 +177,7 @@ Three detection methods run on the audio: 1. **Attention tone detection** — [Goertzel algorithm](https://en.wikipedia.org/wiki/Goertzel_algorithm) tuned to 853 Hz and 960 Hz (the EAS dual-tone attention signal), using energy-ratio analysis. Also runs on bandpass-filtered audio (800-1000 Hz) to catch tones buried under speech or music. 2. **FSK detection** — Detects the FSK modulation pattern (mark at 2083.3 Hz, space at 1562.5 Hz). The default mode uses Goertzel energy ratios with mark/space alternation validation. The sensitive mode applies narrow bandpass filters around each frequency and detects anti-correlation between the mark and space energy envelopes using Pearson correlation. -3. **SAME header decoding** — Pipes audio through [SoX](https://sox.sourceforge.net/) into [multimon-ng](https://github.com/EliasOenal/multimon-ng) for full SAME protocol decoding with error correction. +3. **SAME header decoding** — Converts audio via [FFmpeg](https://ffmpeg.org/) and pipes into [multimon-ng](https://github.com/EliasOenal/multimon-ng) for full SAME protocol decoding with error correction. Decoded SAME headers are parsed and enriched with human-readable lookups from 3,235 FIPS county codes and all standard EAS originator/event codes. diff --git a/src/audio.js b/src/audio.js index d7f0ad6..166c255 100644 --- a/src/audio.js +++ b/src/audio.js @@ -7,9 +7,9 @@ const SAMPLE_RATE = 22050; /** * Read an audio file and return mono PCM samples as a Float64Array at 22050 Hz. - * Uses sox for format conversion. Also accepts raw s16le PCM if `raw` option is set. + * Uses ffmpeg for format conversion. Also accepts raw s16le PCM if `raw` option is set. * - * For non-raw input, sox writes to a temp raw file allowing files of any length + * For non-raw input, ffmpeg writes to a temp raw file allowing files of any length * to be processed. The temp file path is returned as `rawPath` so downstream * consumers (e.g., multimon-ng) can reuse it without re-converting. * @@ -29,20 +29,20 @@ export function readAudio(filePath, { raw = false, sampleRate = SAMPLE_RATE } = const dir = mkdtempSync(join(tmpdir(), "eas-")); rawPath = join(dir, "audio.raw"); - execFileSync("sox", [ + execFileSync("ffmpeg", [ + "-i", filePath, - "-t", - "raw", - "-e", - "signed-integer", - "-b", - "16", - "-r", + "-f", + "s16le", + "-acodec", + "pcm_s16le", + "-ar", String(sampleRate), - "-c", + "-ac", "1", + "-y", rawPath, - ]); + ], { stdio: ["pipe", "pipe", "pipe"] }); buf = readFileSync(rawPath); } @@ -57,9 +57,9 @@ export function readAudio(filePath, { raw = false, sampleRate = SAMPLE_RATE } = } /** - * Apply a sox bandpass filter to a raw PCM file and return filtered samples. - * Used to isolate EAS frequency bands before energy-ratio analysis, improving - * detection of tones buried under speech or music. + * Apply a bandpass filter to a raw PCM file and return filtered samples. + * Uses ffmpeg's sinc FIR filter generator with afir convolution to produce + * a sharp bandpass response equivalent to sox's sinc filter. * * @param {string} rawPath - Path to s16le 22050 Hz mono raw PCM file * @param {number} lowFreq - Lower edge of the bandpass filter in Hz @@ -71,33 +71,28 @@ export function bandpassFilter(rawPath, lowFreq, highFreq, sampleRate = SAMPLE_R const dir = mkdtempSync(join(tmpdir(), "eas-bp-")); const filteredPath = join(dir, "filtered.raw"); + // Generate a FIR bandpass kernel with ffmpeg's sinc filter (hp + lp cutoffs), + // then apply it to the audio with afir. This matches sox's sinc filter quality: + // steep rolloff, flat passband, linear phase. try { - execFileSync("sox", [ - "-t", - "raw", - "-e", - "signed-integer", - "-b", - "16", - "-r", + execFileSync("ffmpeg", [ + "-f", + "s16le", + "-ar", String(sampleRate), - "-c", + "-ac", "1", + "-i", rawPath, - "-t", - "raw", - "-e", - "signed-integer", - "-b", - "16", - "-r", - String(sampleRate), - "-c", - "1", + "-filter_complex", + `sinc=hp=${lowFreq}:lp=${highFreq}:r=${sampleRate}[ir];[0:a][ir]afir`, + "-f", + "s16le", + "-acodec", + "pcm_s16le", + "-y", filteredPath, - "sinc", - `${lowFreq}-${highFreq}`, - ]); + ], { stdio: ["pipe", "pipe", "pipe"] }); const buf = readFileSync(filteredPath); const sampleCount = buf.length / 2; diff --git a/src/fsk-detect-sensitive.js b/src/fsk-detect-sensitive.js index 6b36869..397e039 100644 --- a/src/fsk-detect-sensitive.js +++ b/src/fsk-detect-sensitive.js @@ -7,7 +7,7 @@ * alternate at the baud rate. Music harmonics tend to rise and fall together * (positive correlation). * - * Uses narrow bandpass filters (±50 Hz) via sox to isolate each FSK frequency, + * Uses narrow bandpass filters (±50 Hz) via ffmpeg to isolate each FSK frequency, * then computes energy envelopes and Pearson correlation over sliding regions. * The bandpass improves sensitivity by removing competing frequencies that * would dilute the correlation signal.