Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ tmp/
.DS_Store
sample_audios/
tests/
crates/*/tests/
docs/
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ jobs:
- name: Install ffmpeg (ffmpeg pipe test)
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
- name: Run clippy on the Python bindings
run: cargo clippy --all-targets --features python -- -D warnings
run: cargo clippy -p audio-pattern-detector-core --all-targets --features python -- -D warnings
- name: Build Python extension
env:
PYO3_PYTHON: python
run: cargo build --features python --lib
run: cargo build -p audio-pattern-detector-core --features python --lib
- name: Run Python binding tests
run: python -m unittest tests.test_python_bindings
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ jobs:
uses: Swatinem/rust-cache@v2

- name: Build binary
run: cargo build --release --locked --target ${{ matrix.target }}
run: cargo build --release --locked -p audio-pattern-detector --target ${{ matrix.target }}

- name: Package
shell: bash
Expand Down
14 changes: 7 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
no backward compatibility, so feel free to make breaking changes as needed. Just make sure to
strict no backward compatibility no matter what, so feel free to make breaking changes as needed. Just make sure to
do `cargo clippy --all-targets -- -D warnings` after changes to make sure code style is correct and then `cargo test` as needed.

- Use `./tmp` as the temporary working directory for debug output, scratch files, etc. It is gitignored.
- The project is a single Rust crate at the repo root (library + `audio-pattern-detector` binary). See `docs/development.md` for the code layout.
- Python bindings (PyO3) live in `src/python.rs` behind the `python` feature, with type stubs in `audio_pattern_detector.pyi`; keep both in sync. When touching them also run `cargo clippy --all-targets --features python -- -D warnings` and the tests in `tests/test_python_bindings.py`. See `docs/python.md`.
- The project is a Cargo workspace: `crates/core` (`audio-pattern-detector-core`, the library) and `crates/cli` (`audio-pattern-detector`, the binary). Keep CLI-only dependencies (e.g. clap) out of the core crate. See `docs/development.md` for the code layout.
- Python bindings (PyO3) live in `crates/core/src/python.rs` behind the core crate's `python` feature, with type stubs in `audio_pattern_detector.pyi`; keep both in sync. When touching them also run `cargo clippy -p audio-pattern-detector-core --all-targets --features python -- -D warnings` and the tests in `tests/test_python_bindings.py`. See `docs/python.md`.

## Numerical code

- Numerical routines live in `src/dsp/` and are implemented in the crate even if they seem simple (e.g. Pearson correlation) — the user prefers a small dependency footprint over pulling in numerical crates.
- Numerical routines live in `crates/core/src/dsp/` and are implemented in the crate even if they seem simple (e.g. Pearson correlation) — the user prefers a small dependency footprint over pulling in numerical crates.
- They follow scipy/numpy/pyloudnorm semantics (`find_peaks`, `resample`, `correlate`, `hanning`, BS.1770 loudness) because the detection thresholds were tuned against those. Keep that behaviour when touching them.
- Rounding is the exception: use Rust's standard `f64::round` (ties away from zero), not Python's round-half-to-even.

## Testing

- Use test data constants at the top of test files (e.g. `RAINBOW_INTRO_PATTERN`, `RAINBOW_INTRO_AUDIO`) instead of hardcoding paths — makes swapping clips a one-line change.
- Tests should assert exact expected values, not just lengths. For example, assert the full output vector, not just `out.len() == 5`.
- Shared integration-test helpers go in `tests/common/mod.rs`.
- Sample audio files go in `sample_audios/` (clips in `sample_audios/clips/`). Keep them small (~30s audio sections).
- Shared integration-test helpers go in `crates/core/tests/common/mod.rs`. CLI tests live in `crates/cli/tests/`.
- Sample audio files go in the repo-root `sample_audios/` (clips in `sample_audios/clips/`); Rust tests reference them as `../../sample_audios/...`. Keep them small (~30s audio sections).
- When a clip produces false positives or is too short to work reliably, replace it rather than loosening thresholds.

## Debugging opus/lossy audio
Expand All @@ -28,4 +28,4 @@ do `cargo clippy --all-targets -- -D warnings` after changes to make sure code s

## Version bumping

- Bump the version in `Cargo.toml`, then run `cargo build` (or `cargo update -p audio-pattern-detector`) to update `Cargo.lock`. The Python wheel takes its version from `Cargo.toml`.
- Bump `[workspace.package] version` in the root `Cargo.toml` (both crates inherit it), then run `cargo build` to update `Cargo.lock`. The Python wheel takes its version from it too.
13 changes: 11 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 8 additions & 27 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,35 +1,16 @@
[package]
name = "audio-pattern-detector"
version = "0.4.0"
[workspace]
resolver = "2"
members = ["crates/core", "crates/cli"]

[workspace.package]
version = "0.4.1"
edition = "2021"
description = "Detects audio patterns (intros, breaks, station beeps) in audio files and streams"
authors = ["Andrew Chen <hello@andrewtheguy.com>"]
readme = "README.md"

[lib]
name = "audio_pattern_detector"
crate-type = ["rlib", "cdylib"]

[features]
default = []
# Python bindings (PyO3), built with maturin. See docs/python.md.
python = ["dep:pyo3", "pyo3/extension-module"]

[[bin]]
name = "audio-pattern-detector"
path = "src/main.rs"

[dependencies]
base64 = "0.23"
clap = { version = "4.6", features = ["derive"] }
pyo3 = { version = "0.28", optional = true, features = ["abi3-py39"] }
realfft = "3.5"
rustfft = "6.4"
[workspace.dependencies]
audio-pattern-detector-core = { path = "crates/core" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
toml = "1.1"

[dev-dependencies]
tempfile = "3"

[profile.release]
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ COPY . .
# Build the release binary with architecture-specific cache mounts
RUN --mount=type=cache,target=/usr/local/cargo/registry,id=cargo-registry-v2-${TARGETARCH} \
--mount=type=cache,target=/build/target,id=cargo-target-v2-${TARGETARCH} \
cargo build --release --locked && \
cargo build --release --locked -p audio-pattern-detector && \
cp target/release/audio-pattern-detector /audio-pattern-detector

# Runtime stage - minimal image without ffmpeg (builds from source).
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ Download the archive for your platform (Linux x86_64, Linux arm64, macOS Apple S
### Install from source (requires Rust toolchain)

```shell
cargo install --git https://github.com/andrewtheguy/audio_pattern_detector.git --tag vx.x.x
cargo install --git https://github.com/andrewtheguy/audio_pattern_detector.git --tag vx.x.x audio-pattern-detector
```

### Docker image

`ghcr.io/andrewtheguy/audio_pattern_detector:<tag>` is published with each release (`v<version>`, plus `latest` for releases from `main`). It contains only the binary at `/usr/local/bin/audio-pattern-detector` (no ffmpeg), so the usual use is copying it into another image:

```dockerfile
COPY --from=ghcr.io/andrewtheguy/audio_pattern_detector:v0.4.0 /usr/local/bin/audio-pattern-detector /usr/local/bin/audio-pattern-detector
COPY --from=ghcr.io/andrewtheguy/audio_pattern_detector:v0.4.1 /usr/local/bin/audio-pattern-detector /usr/local/bin/audio-pattern-detector
```

The release workflow builds the image for amd64 and arm64 from the binaries it has already compiled (`runtime-prebuilt` target). To build it locally from source for the host architecture: `docker build --target runtime -t audio-pattern-detector .`
Expand Down Expand Up @@ -146,8 +146,8 @@ cargo clippy --all-targets -- -D warnings # Linting
cargo test # Testing

# Python bindings (see docs/python.md)
cargo clippy --all-targets --features python -- -D warnings
cargo build --features python --lib && python -m unittest tests.test_python_bindings
cargo clippy -p audio-pattern-detector-core --all-targets --features python -- -D warnings
cargo build -p audio-pattern-detector-core --features python --lib && python -m unittest tests.test_python_bindings
```

See [docs/development.md](docs/development.md) for more details.
19 changes: 19 additions & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "audio-pattern-detector"
version.workspace = true
edition.workspace = true
authors.workspace = true
description = "Command-line interface for audio-pattern-detector-core"
readme = "../../README.md"

[[bin]]
name = "audio-pattern-detector"
path = "src/main.rs"

[dependencies]
audio-pattern-detector-core.workspace = true
clap = { version = "4.6", features = ["derive"] }
serde_json.workspace = true

[dev-dependencies]
tempfile.workspace = true
4 changes: 2 additions & 2 deletions src/main.rs → crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use std::process::ExitCode;
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use serde_json::{json, Map, Value};

use audio_pattern_detector::time_format::{seconds_to_ms, seconds_to_time};
use audio_pattern_detector::{
use audio_pattern_detector_core::time_format::{seconds_to_ms, seconds_to_time};
use audio_pattern_detector_core::{
match_pattern, match_pattern_multiplexed, match_pattern_wav_stream, AudioClip, AudioPatternDetector,
DetectorOptions, Error, MatchOptions, Result, DEFAULT_TARGET_SAMPLE_RATE,
};
Expand Down
48 changes: 39 additions & 9 deletions tests/cli_integration.rs → crates/cli/tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,23 @@ use std::fs;
use std::io::Write;
use std::process::{Command, Output, Stdio};

use audio_pattern_detector::stream::resample_audio;
use audio_pattern_detector::wav::{encode_wav_i16, load_wav_file};
use audio_pattern_detector_core::stream::resample_audio;
use audio_pattern_detector_core::wav::{encode_wav_i16, load_wav_file};
use serde_json::Value;

const CLI_BIN: &str = env!("CARGO_BIN_EXE_audio-pattern-detector");

const RTHK_BEEP_PATTERN: &str = "sample_audios/clips/rthk_beep.apd.toml";
const RTHK_BEEP_AUDIO: &str = "sample_audios/rthk_section_with_beep.wav";
const RTHK_BEEP_PATTERN: &str = "../../sample_audios/clips/rthk_beep.apd.toml";
const RTHK_BEEP_AUDIO: &str = "../../sample_audios/rthk_section_with_beep.wav";
const RTHK_BEEP_AUDIO_NAME: &str = "rthk_section_with_beep.wav";
const RTHK_BEEP_AUDIO_16K: &str = "sample_audios/test_16khz/rthk_section_with_beep_16k.wav";
const RTHK_BEEP_AUDIO_16K: &str = "../../sample_audios/test_16khz/rthk_section_with_beep_16k.wav";
const RTHK_BEEP_CLIP_NAME: &str = "rthk_beep";
const CBS_NEWS_PATTERN: &str = "sample_audios/clips/cbs_news.wav";
const CBS_NEWS_AUDIO: &str = "sample_audios/cbs_news_audio_section.wav";
const RTHK_BEEP_EXPECTED_TIMES: [f64; 2] = [1.407375, 2.419125];
const CBS_NEWS_PATTERN: &str = "../../sample_audios/clips/cbs_news.wav";
const CBS_NEWS_AUDIO: &str = "../../sample_audios/cbs_news_audio_section.wav";
const CBS_NEWS_CLIP_NAME: &str = "cbs_news";
const RAINBOW_INTRO_PATTERN: &str = "sample_audios/clips/天空下的彩虹intro.wav";
const PATTERN_FOLDER: &str = "sample_audios/clips";
const RAINBOW_INTRO_PATTERN: &str = "../../sample_audios/clips/天空下的彩虹intro.wav";
const PATTERN_FOLDER: &str = "../../sample_audios/clips";
const NONEXISTENT_FILE: &str = "nonexistent.wav";
/// Pattern name that would escape the debug directory if used as a path.
const TRAVERSAL_CLIP_NAME: &str = "../../escape";
Expand Down Expand Up @@ -494,6 +495,35 @@ fn show_config_nonexistent_pattern() {
assert_ne!(result.code, 0);
}

// WAV input is decoded natively: the CLI runs with PATH set to an empty
// directory, so ffmpeg cannot be found.
#[test]
fn match_wav_without_ffmpeg_available() {
let empty_path = tempfile::tempdir().expect("failed to create temp dir");
let output = Command::new(CLI_BIN)
.args(["match", RTHK_BEEP_AUDIO, "--pattern-file", RTHK_BEEP_PATTERN])
.env("PATH", empty_path.path())
.stdin(Stdio::null())
.output()
.expect("failed to run CLI");
let result = CliResult::from_output(output);
assert_eq!(result.code, 0, "stderr: {}", result.stderr);

let events = result.events();
let pattern_events = pattern_events(&events);
for event in &pattern_events {
assert_eq!(event["clip_name"], RTHK_BEEP_CLIP_NAME);
}
let times: Vec<f64> = pattern_events
.iter()
.map(|event| event["timestamp_ms"].as_f64().expect("timestamp_ms is a number") / 1000.0)
.collect();
assert_eq!(times.len(), RTHK_BEEP_EXPECTED_TIMES.len(), "found {times:?}");
for (actual, expected) in times.iter().zip(RTHK_BEEP_EXPECTED_TIMES) {
assert!((actual - expected).abs() < 0.01, "expected ~{expected}s, got {actual}s");
}
}

// --- 16kHz Audio Auto-Conversion Tests ---

#[test]
Expand Down
28 changes: 28 additions & 0 deletions crates/core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "audio-pattern-detector-core"
version.workspace = true
edition.workspace = true
authors.workspace = true
description = "Detects audio patterns (intros, breaks, station beeps) in audio files and streams"
readme = "../../README.md"

[lib]
name = "audio_pattern_detector_core"
crate-type = ["rlib", "cdylib"]

[features]
default = []
# Python bindings (PyO3), built with maturin. See docs/python.md.
python = ["dep:pyo3", "pyo3/extension-module"]

[dependencies]
base64 = "0.23"
pyo3 = { version = "0.28", optional = true, features = ["abi3-py39"] }
realfft = "3.5"
rustfft = "6.4"
serde.workspace = true
serde_json.workspace = true
toml = "1.1"

[dev-dependencies]
tempfile.workspace = true
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
10 changes: 5 additions & 5 deletions tests/audio_utils.rs → crates/core/tests/audio_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@

use std::path::Path;

use audio_pattern_detector::ffmpeg::is_ffmpeg_available;
use audio_pattern_detector::stream::resample_audio;
use audio_pattern_detector::wav::{load_wav_file, write_wav_file};
use audio_pattern_detector::{AudioClip, Result};
use audio_pattern_detector_core::ffmpeg::is_ffmpeg_available;
use audio_pattern_detector_core::stream::resample_audio;
use audio_pattern_detector_core::wav::{load_wav_file, write_wav_file};
use audio_pattern_detector_core::{AudioClip, Result};
use tempfile::TempDir;

const CBS_NEWS_CLIP: &str = "sample_audios/clips/cbs_news.wav";
const CBS_NEWS_CLIP: &str = "../../sample_audios/clips/cbs_news.wav";
const NONEXISTENT_WAV: &str = "nonexistent_file.wav";
const TEMP_WAV_NAME: &str = "temp.wav";

Expand Down
2 changes: 1 addition & 1 deletion tests/common/mod.rs → crates/core/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! Helpers shared by the integration tests.
#![allow(dead_code)]

use audio_pattern_detector::{AudioClip, AudioStream, DEFAULT_TARGET_SAMPLE_RATE};
use audio_pattern_detector_core::{AudioClip, AudioStream, DEFAULT_TARGET_SAMPLE_RATE};

pub const SR: u32 = DEFAULT_TARGET_SAMPLE_RATE;

Expand Down
14 changes: 7 additions & 7 deletions tests/detector_api.rs → crates/core/tests/detector_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,18 @@

use std::path::Path;

use audio_pattern_detector::ffmpeg::FfmpegSource;
use audio_pattern_detector::{
use audio_pattern_detector_core::ffmpeg::FfmpegSource;
use audio_pattern_detector_core::{
AudioClip, AudioPatternDetector, AudioStream, DetectorConfig, DetectorOptions, PatternDetectedCallback,
PeakTimes, DEFAULT_SECONDS_PER_CHUNK, DEFAULT_TARGET_SAMPLE_RATE,
};

const RTHK_BEEP_PATTERN: &str = "sample_audios/clips/rthk_beep.apd.toml";
const CBS_NEWS_PATTERN: &str = "sample_audios/clips/cbs_news.wav";
const RAINBOW_INTRO_PATTERN: &str = "sample_audios/clips/天空下的彩虹intro.wav";
const RTHK_BEEP_PATTERN: &str = "../../sample_audios/clips/rthk_beep.apd.toml";
const CBS_NEWS_PATTERN: &str = "../../sample_audios/clips/cbs_news.wav";
const RAINBOW_INTRO_PATTERN: &str = "../../sample_audios/clips/天空下的彩虹intro.wav";

const RTHK_BEEP_AUDIO: &str = "sample_audios/rthk_section_with_beep.wav";
const CBS_NEWS_AUDIO: &str = "sample_audios/cbs_news_audio_section.wav";
const RTHK_BEEP_AUDIO: &str = "../../sample_audios/rthk_section_with_beep.wav";
const CBS_NEWS_AUDIO: &str = "../../sample_audios/cbs_news_audio_section.wav";

const RTHK_BEEP_NAME: &str = "rthk_beep";
const CBS_NEWS_NAME: &str = "cbs_news";
Expand Down
Loading
Loading