Watch
0
0
Fork
You've already forked hyperhive
0

swarm-stt: only files that start like an audio container reach ffmpeg

This commit is contained in:
damocles 2026-11-11 22:28:32 +01:00 • committed by mara
commit 33e63091d5
4 changed files with 97 additions and 7 deletions

View file

@ -31,7 +31,9 @@ into place with a rename, so a reader never sees half a transcript.
The `Engine` trait takes bytes and returns text. `Whisper` is the real one: The `Engine` trait takes bytes and returns text. `Whisper` is the real one:
`ffmpeg` decodes any container to 16 kHz mono WAV, cutting it at `max_secs`, `ffmpeg` decodes any container to 16 kHz mono WAV, cutting it at `max_secs`,
and `whisper-cli` from whisper.cpp reads the WAV with the configured model and and `whisper-cli` from whisper.cpp reads the WAV with the configured model and
language detection on. Decoding is pipe-only (`-protocol_whitelist pipe`): the audio comes from language detection on. Before anything decodes, `sniff` checks that the file starts like an audio
container (Ogg, WAV, FLAC, MP3, AMR, MP4 or M4A, WebM); the daemon refuses a playlist, an image or a
script without starting `ffmpeg`. Decoding is pipe-only (`-protocol_whitelist pipe`): the audio comes from
rooms with outside people, so a file that names other files or URLs gets no rooms with outside people, so a file that names other files or URLs gets no
way to open them. The service kills a run that outlasts its timeout (decode and transcribe way to open them. The service kills a run that outlasts its timeout (decode and transcribe
together) and fails the request, so one hung child can't hold the single together) and fails the request, so one hung child can't hold the single

View file

@ -36,6 +36,12 @@ pub struct Whisper {
#[async_trait] #[async_trait]
impl Engine for Whisper { impl Engine for Whisper {
async fn transcribe(&self, audio: &[u8]) -> Result<String, String> { async fn transcribe(&self, audio: &[u8]) -> Result<String, String> {
if !crate::sniff::is_audio(audio) {
return Err(
"the file does not look like audio (Ogg, WAV, FLAC, MP3, AMR, MP4/M4A, WebM)"
.to_owned(),
);
}
let dir = tempfile_in(&self.scratch) let dir = tempfile_in(&self.scratch)
.await .await
.map_err(|e| format!("making a scratch directory: {e}"))?; .map_err(|e| format!("making a scratch directory: {e}"))?;
@ -157,8 +163,8 @@ mod tests {
async fn audio_goes_through_ffmpeg_then_whisper_and_the_text_is_trimmed() { async fn audio_goes_through_ffmpeg_then_whisper_and_the_text_is_trimmed() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let engine = whisper(dir.path(), FFMPEG_OK, WHISPER_ECHO); let engine = whisper(dir.path(), FFMPEG_OK, WHISPER_ECHO);
let text = engine.transcribe(b" hello there \n").await.unwrap(); let text = engine.transcribe(b"OggS hello there \n").await.unwrap();
assert_eq!(text, "hello there"); assert_eq!(text, "OggS hello there");
assert_eq!( assert_eq!(
std::fs::read_dir(dir.path().join("scratch")) std::fs::read_dir(dir.path().join("scratch"))
.unwrap() .unwrap()
@ -173,7 +179,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let mut engine = whisper(dir.path(), "sleep 60", WHISPER_ECHO); let mut engine = whisper(dir.path(), "sleep 60", WHISPER_ECHO);
engine.timeout = std::time::Duration::from_millis(200); engine.timeout = std::time::Duration::from_millis(200);
let err = engine.transcribe(b"x").await.unwrap_err(); let err = engine.transcribe(b"OggSx").await.unwrap_err();
assert!(err.contains("timed out"), "{err}"); assert!(err.contains("timed out"), "{err}");
assert_eq!( assert_eq!(
std::fs::read_dir(dir.path().join("scratch")) std::fs::read_dir(dir.path().join("scratch"))
@ -192,7 +198,7 @@ mod tests {
record.display() record.display()
); );
let engine = whisper(dir.path(), &ffmpeg, WHISPER_ECHO); let engine = whisper(dir.path(), &ffmpeg, WHISPER_ECHO);
engine.transcribe(b"x").await.unwrap(); engine.transcribe(b"OggSx").await.unwrap();
let args = std::fs::read_to_string(record).unwrap(); let args = std::fs::read_to_string(record).unwrap();
assert!( assert!(
args.contains("-protocol_whitelist pipe -i pipe:0"), args.contains("-protocol_whitelist pipe -i pipe:0"),
@ -200,11 +206,25 @@ mod tests {
); );
} }
#[tokio::test]
async fn a_file_that_is_not_audio_never_reaches_ffmpeg() {
let dir = tempfile::tempdir().unwrap();
let record = dir.path().join("ran");
let ffmpeg = format!("touch {}", record.display());
let engine = whisper(dir.path(), &ffmpeg, WHISPER_ECHO);
let err = engine
.transcribe(b"#EXTM3U\nfile:///etc/passwd")
.await
.unwrap_err();
assert!(err.contains("does not look like audio"), "{err}");
assert!(!record.exists(), "ffmpeg was started");
}
#[tokio::test] #[tokio::test]
async fn an_ffmpeg_failure_is_reported_with_its_stderr() { async fn an_ffmpeg_failure_is_reported_with_its_stderr() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let engine = whisper(dir.path(), "echo 'bad data' >&2; exit 1", WHISPER_ECHO); let engine = whisper(dir.path(), "echo 'bad data' >&2; exit 1", WHISPER_ECHO);
let err = engine.transcribe(b"x").await.unwrap_err(); let err = engine.transcribe(b"OggSx").await.unwrap_err();
assert!( assert!(
err.contains("could not decode") && err.contains("bad data"), err.contains("could not decode") && err.contains("bad data"),
"{err}" "{err}"
@ -215,7 +235,7 @@ mod tests {
async fn a_whisper_failure_is_reported_with_its_stderr() { async fn a_whisper_failure_is_reported_with_its_stderr() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let engine = whisper(dir.path(), FFMPEG_OK, "echo 'no model' >&2; exit 2"); let engine = whisper(dir.path(), FFMPEG_OK, "echo 'no model' >&2; exit 2");
let err = engine.transcribe(b"x").await.unwrap_err(); let err = engine.transcribe(b"OggSx").await.unwrap_err();
assert!( assert!(
err.contains("whisper-cli failed") && err.contains("no model"), err.contains("whisper-cli failed") && err.contains("no model"),
"{err}" "{err}"

View file

@ -9,12 +9,16 @@
//! - an in-flight map, so two requests for one memo that arrive together share //! - an in-flight map, so two requests for one memo that arrive together share
//! one engine run instead of starting two. //! one engine run instead of starting two.
//! //!
//! The audio comes from rooms with outside people, so [`sniff`] checks that a file
//! starts like an audio container before any decoder sees it.
//!
//! The engine is behind the [`Engine`] trait. [`engine::Whisper`] decodes with //! The engine is behind the [`Engine`] trait. [`engine::Whisper`] decodes with
//! `ffmpeg` and transcribes with `whisper-cli`; the tests use a fake. //! `ffmpeg` and transcribes with `whisper-cli`; the tests use a fake.
pub mod cache; pub mod cache;
pub mod engine; pub mod engine;
mod service; mod service;
pub mod sniff;
pub use engine::Engine; pub use engine::Engine;
pub use service::{Config, Error, Reply, Service}; pub use service::{Config, Error, Reply, Service};

64
swarm-stt/src/sniff.rs Normal file
View file

@ -0,0 +1,64 @@
//! A check of the first bytes of a file against the audio containers voice
//! memos come in, before anything decodes it.
//!
//! The audio comes from matrix rooms with outside people, so the decoder only
//! ever sees files that start like an audio container. A file that claims to
//! be anything else (a playlist, an image, a script) stops here, which keeps
//! most of `ffmpeg`'s demuxers out of reach.
/// `true` when `bytes` start like Ogg (Opus, Vorbis), WAV, FLAC, MP3 (with or
/// without an `ID3` tag), AMR, `MP4` or `M4A`, Matroska or `WebM`.
#[must_use]
pub fn is_audio(bytes: &[u8]) -> bool {
let starts = |magic: &[u8]| bytes.starts_with(magic);
starts(b"OggS")
|| (starts(b"RIFF") && bytes.get(8..12) == Some(b"WAVE"))
|| starts(b"fLaC")
|| starts(b"ID3")
|| starts(b"#!AMR")
// An MPEG audio frame: eleven set bits of sync.
|| matches!(bytes, [0xFF, second, ..] if second & 0xE0 == 0xE0)
// The `ftyp` box of an ISO base media file (MP4, M4A, 3GP).
|| bytes.get(4..8) == Some(b"ftyp")
// The EBML header of Matroska and WebM.
|| starts(&[0x1A, 0x45, 0xDF, 0xA3])
}
#[cfg(test)]
mod tests {
use super::is_audio;
#[test]
fn the_containers_voice_memos_come_in_pass() {
for (name, head) in [
("ogg", &b"OggS\0\x02"[..]),
("wav", b"RIFF\x24\0\0\0WAVEfmt "),
("flac", b"fLaC\0"),
("mp3 with a tag", b"ID3\x04\0"),
("mp3 frame", &[0xFF, 0xFB, 0x90, 0x00]),
("amr", b"#!AMR\n"),
("m4a", b"\0\0\0\x20ftypM4A "),
("webm", &[0x1A, 0x45, 0xDF, 0xA3, 0x9F]),
] {
assert!(is_audio(head), "{name} was refused");
}
}
#[test]
fn files_that_are_not_audio_containers_are_refused() {
for (name, head) in [
("an HLS playlist", &b"#EXTM3U\n#EXT-X-VERSION:3\n"[..]),
("a png", b"\x89PNG\r\n\x1a\n"),
("a script", b"#!/bin/sh\nid\n"),
(
"a concat list",
b"ffconcat version 1.0\nfile '/etc/passwd'\n",
),
("a wave-less riff (avi)", b"RIFF\x24\0\0\0AVI LIST"),
("empty", b""),
("one byte", &[0xFF]),
] {
assert!(!is_audio(head), "{name} was accepted");
}
}
}