From a39357667136b5d9c61674ccba8073e13d47f202 Mon Sep 17 00:00:00 2001 From: Andrew Chen Date: Tue, 6 Oct 2026 14:40:47 -0700 Subject: [PATCH] Split into core library and CLI crates, bump to 0.4.1 Turn the repo into a Cargo workspace: - crates/core: audio-pattern-detector-core (lib audio_pattern_detector_core) with the detector, audio I/O and the PyO3 bindings. No clap dependency. - crates/cli: audio-pattern-detector, the binary, depending on the core crate. The version is shared via [workspace.package]. pyproject.toml points maturin at the core crate and keeps the Python module name audio_pattern_detector. Integration tests moved with their crate and reference the repo-root sample_audios/ as ../../sample_audios/. The no-ffmpeg CLI test moved to the CLI crate since it needs the binary. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 1 + .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 2 +- AGENTS.md | 14 ++--- Cargo.lock | 13 +++- Cargo.toml | 35 +++-------- Dockerfile | 2 +- README.md | 8 +-- crates/cli/Cargo.toml | 19 ++++++ {src => crates/cli/src}/main.rs | 4 +- .../cli/tests}/cli_integration.rs | 48 +++++++++++--- crates/core/Cargo.toml | 28 +++++++++ {src => crates/core/src}/audio_clip.rs | 0 {src => crates/core/src}/detector.rs | 0 {src => crates/core/src}/dsp/correlate.rs | 0 {src => crates/core/src}/dsp/loudness.rs | 0 {src => crates/core/src}/dsp/mod.rs | 0 {src => crates/core/src}/dsp/peaks.rs | 0 {src => crates/core/src}/dsp/pearson.rs | 0 {src => crates/core/src}/dsp/resample.rs | 0 {src => crates/core/src}/dsp/spectrum.rs | 0 {src => crates/core/src}/error.rs | 0 {src => crates/core/src}/ffmpeg.rs | 0 {src => crates/core/src}/lib.rs | 0 {src => crates/core/src}/matching.rs | 0 {src => crates/core/src}/pattern_config.rs | 0 {src => crates/core/src}/python.rs | 0 {src => crates/core/src}/stream.rs | 0 {src => crates/core/src}/time_format.rs | 0 {src => crates/core/src}/tone.rs | 0 {src => crates/core/src}/wav.rs | 0 {tests => crates/core/tests}/audio_utils.rs | 10 +-- {tests => crates/core/tests}/common/mod.rs | 2 +- {tests => crates/core/tests}/detector_api.rs | 14 ++--- .../core/tests}/integration_matching.rs | 63 +++++-------------- .../core/tests}/marker_tone_verification.rs | 8 +-- .../core/tests}/pattern_config.rs | 4 +- .../core/tests}/real_data_regressions.rs | 40 ++++++------ {tests => crates/core/tests}/short_clip.rs | 10 +-- .../core/tests}/sliding_window.rs | 14 ++--- docs/development.md | 41 +++++++----- docs/pattern-matching.md | 2 +- docs/python.md | 8 +-- pyproject.toml | 2 + tests/test_python_bindings.py | 6 +- 45 files changed, 223 insertions(+), 179 deletions(-) create mode 100644 crates/cli/Cargo.toml rename {src => crates/cli/src}/main.rs (98%) rename {tests => crates/cli/tests}/cli_integration.rs (91%) create mode 100644 crates/core/Cargo.toml rename {src => crates/core/src}/audio_clip.rs (100%) rename {src => crates/core/src}/detector.rs (100%) rename {src => crates/core/src}/dsp/correlate.rs (100%) rename {src => crates/core/src}/dsp/loudness.rs (100%) rename {src => crates/core/src}/dsp/mod.rs (100%) rename {src => crates/core/src}/dsp/peaks.rs (100%) rename {src => crates/core/src}/dsp/pearson.rs (100%) rename {src => crates/core/src}/dsp/resample.rs (100%) rename {src => crates/core/src}/dsp/spectrum.rs (100%) rename {src => crates/core/src}/error.rs (100%) rename {src => crates/core/src}/ffmpeg.rs (100%) rename {src => crates/core/src}/lib.rs (100%) rename {src => crates/core/src}/matching.rs (100%) rename {src => crates/core/src}/pattern_config.rs (100%) rename {src => crates/core/src}/python.rs (100%) rename {src => crates/core/src}/stream.rs (100%) rename {src => crates/core/src}/time_format.rs (100%) rename {src => crates/core/src}/tone.rs (100%) rename {src => crates/core/src}/wav.rs (100%) rename {tests => crates/core/tests}/audio_utils.rs (97%) rename {tests => crates/core/tests}/common/mod.rs (96%) rename {tests => crates/core/tests}/detector_api.rs (97%) rename {tests => crates/core/tests}/integration_matching.rs (92%) rename {tests => crates/core/tests}/marker_tone_verification.rs (94%) rename {tests => crates/core/tests}/pattern_config.rs (98%) rename {tests => crates/core/tests}/real_data_regressions.rs (79%) rename {tests => crates/core/tests}/short_clip.rs (95%) rename {tests => crates/core/tests}/sliding_window.rs (98%) diff --git a/.dockerignore b/.dockerignore index 2310f7c..13d80de 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,4 +8,5 @@ tmp/ .DS_Store sample_audios/ tests/ +crates/*/tests/ docs/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 093116a..df6e880 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4ea759c..e72e513 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 73af092..57e2a1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,13 @@ -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. @@ -15,8 +15,8 @@ do `cargo clippy --all-targets -- -D warnings` after changes to make sure code s - 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 @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 87aa6c6..d648610 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,10 +54,19 @@ dependencies = [ [[package]] name = "audio-pattern-detector" -version = "0.4.0" +version = "0.4.1" dependencies = [ - "base64", + "audio-pattern-detector-core", "clap", + "serde_json", + "tempfile", +] + +[[package]] +name = "audio-pattern-detector-core" +version = "0.4.1" +dependencies = [ + "base64", "pyo3", "realfft", "rustfft", diff --git a/Cargo.toml b/Cargo.toml index 691426a..c9dfd60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 "] -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] diff --git a/Dockerfile b/Dockerfile index 31b00ff..a510969 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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). diff --git a/README.md b/README.md index 13f6fcd..68fd38c 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ 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 @@ -25,7 +25,7 @@ cargo install --git https://github.com/andrewtheguy/audio_pattern_detector.git - `ghcr.io/andrewtheguy/audio_pattern_detector:` is published with each release (`v`, 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 .` @@ -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. diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml new file mode 100644 index 0000000..8d7302b --- /dev/null +++ b/crates/cli/Cargo.toml @@ -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 diff --git a/src/main.rs b/crates/cli/src/main.rs similarity index 98% rename from src/main.rs rename to crates/cli/src/main.rs index 29c783b..edfeba6 100644 --- a/src/main.rs +++ b/crates/cli/src/main.rs @@ -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, }; diff --git a/tests/cli_integration.rs b/crates/cli/tests/cli_integration.rs similarity index 91% rename from tests/cli_integration.rs rename to crates/cli/tests/cli_integration.rs index b09ee74..a50f65f 100644 --- a/tests/cli_integration.rs +++ b/crates/cli/tests/cli_integration.rs @@ -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"; @@ -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 = 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] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..533c373 --- /dev/null +++ b/crates/core/Cargo.toml @@ -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 diff --git a/src/audio_clip.rs b/crates/core/src/audio_clip.rs similarity index 100% rename from src/audio_clip.rs rename to crates/core/src/audio_clip.rs diff --git a/src/detector.rs b/crates/core/src/detector.rs similarity index 100% rename from src/detector.rs rename to crates/core/src/detector.rs diff --git a/src/dsp/correlate.rs b/crates/core/src/dsp/correlate.rs similarity index 100% rename from src/dsp/correlate.rs rename to crates/core/src/dsp/correlate.rs diff --git a/src/dsp/loudness.rs b/crates/core/src/dsp/loudness.rs similarity index 100% rename from src/dsp/loudness.rs rename to crates/core/src/dsp/loudness.rs diff --git a/src/dsp/mod.rs b/crates/core/src/dsp/mod.rs similarity index 100% rename from src/dsp/mod.rs rename to crates/core/src/dsp/mod.rs diff --git a/src/dsp/peaks.rs b/crates/core/src/dsp/peaks.rs similarity index 100% rename from src/dsp/peaks.rs rename to crates/core/src/dsp/peaks.rs diff --git a/src/dsp/pearson.rs b/crates/core/src/dsp/pearson.rs similarity index 100% rename from src/dsp/pearson.rs rename to crates/core/src/dsp/pearson.rs diff --git a/src/dsp/resample.rs b/crates/core/src/dsp/resample.rs similarity index 100% rename from src/dsp/resample.rs rename to crates/core/src/dsp/resample.rs diff --git a/src/dsp/spectrum.rs b/crates/core/src/dsp/spectrum.rs similarity index 100% rename from src/dsp/spectrum.rs rename to crates/core/src/dsp/spectrum.rs diff --git a/src/error.rs b/crates/core/src/error.rs similarity index 100% rename from src/error.rs rename to crates/core/src/error.rs diff --git a/src/ffmpeg.rs b/crates/core/src/ffmpeg.rs similarity index 100% rename from src/ffmpeg.rs rename to crates/core/src/ffmpeg.rs diff --git a/src/lib.rs b/crates/core/src/lib.rs similarity index 100% rename from src/lib.rs rename to crates/core/src/lib.rs diff --git a/src/matching.rs b/crates/core/src/matching.rs similarity index 100% rename from src/matching.rs rename to crates/core/src/matching.rs diff --git a/src/pattern_config.rs b/crates/core/src/pattern_config.rs similarity index 100% rename from src/pattern_config.rs rename to crates/core/src/pattern_config.rs diff --git a/src/python.rs b/crates/core/src/python.rs similarity index 100% rename from src/python.rs rename to crates/core/src/python.rs diff --git a/src/stream.rs b/crates/core/src/stream.rs similarity index 100% rename from src/stream.rs rename to crates/core/src/stream.rs diff --git a/src/time_format.rs b/crates/core/src/time_format.rs similarity index 100% rename from src/time_format.rs rename to crates/core/src/time_format.rs diff --git a/src/tone.rs b/crates/core/src/tone.rs similarity index 100% rename from src/tone.rs rename to crates/core/src/tone.rs diff --git a/src/wav.rs b/crates/core/src/wav.rs similarity index 100% rename from src/wav.rs rename to crates/core/src/wav.rs diff --git a/tests/audio_utils.rs b/crates/core/tests/audio_utils.rs similarity index 97% rename from tests/audio_utils.rs rename to crates/core/tests/audio_utils.rs index b66ea07..fa081bf 100644 --- a/tests/audio_utils.rs +++ b/crates/core/tests/audio_utils.rs @@ -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"; diff --git a/tests/common/mod.rs b/crates/core/tests/common/mod.rs similarity index 96% rename from tests/common/mod.rs rename to crates/core/tests/common/mod.rs index 02f71f0..d45a97f 100644 --- a/tests/common/mod.rs +++ b/crates/core/tests/common/mod.rs @@ -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; diff --git a/tests/detector_api.rs b/crates/core/tests/detector_api.rs similarity index 97% rename from tests/detector_api.rs rename to crates/core/tests/detector_api.rs index fc79240..49128bd 100644 --- a/tests/detector_api.rs +++ b/crates/core/tests/detector_api.rs @@ -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"; diff --git a/tests/integration_matching.rs b/crates/core/tests/integration_matching.rs similarity index 92% rename from tests/integration_matching.rs rename to crates/core/tests/integration_matching.rs index 37caeb4..c4d27a5 100644 --- a/tests/integration_matching.rs +++ b/crates/core/tests/integration_matching.rs @@ -7,10 +7,10 @@ use std::io::{BufReader, Write}; use std::path::Path; use std::process::{Command, Stdio}; -use audio_pattern_detector::ffmpeg::FfmpegSource; -use audio_pattern_detector::stream::{resample_audio, WavFileSource}; -use audio_pattern_detector::wav::{load_wav_file, write_wav_file, WavReader, WavSpec}; -use audio_pattern_detector::{ +use audio_pattern_detector_core::ffmpeg::FfmpegSource; +use audio_pattern_detector_core::stream::{resample_audio, WavFileSource}; +use audio_pattern_detector_core::wav::{load_wav_file, write_wav_file, WavReader, WavSpec}; +use audio_pattern_detector_core::{ match_pattern, AudioClip, AudioPatternDetector, AudioStream, DetectorOptions, MatchOptions, PeakTimes, SampleSource, DEFAULT_TARGET_SAMPLE_RATE, }; @@ -18,27 +18,27 @@ use audio_pattern_detector::{ // --- Test Data Constants --- // Centralised paths so swapping a clip only requires editing one place. -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_PATTERN: &str = "../../sample_audios/clips/cbs_news.wav"; +const CBS_NEWS_AUDIO: &str = "../../sample_audios/cbs_news_audio_section.wav"; const CBS_NEWS_NAME: &str = "cbs_news"; const CBS_NEWS_EXPECTED_TIME: f64 = 25.89875; -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_NAME: &str = "rthk_beep"; const RTHK_BEEP_EXPECTED_TIMES: [f64; 2] = [1.407375, 2.419125]; -const RAINBOW_INTRO_PATTERN: &str = "sample_audios/clips/天空下的彩虹intro.wav"; -const RAINBOW_INTRO_AUDIO: &str = "sample_audios/am1430_section_with_rainbow_intro.wav"; +const RAINBOW_INTRO_PATTERN: &str = "../../sample_audios/clips/天空下的彩虹intro.wav"; +const RAINBOW_INTRO_AUDIO: &str = "../../sample_audios/am1430_section_with_rainbow_intro.wav"; const RAINBOW_INTRO_NAME: &str = "天空下的彩虹intro"; const RAINBOW_INTRO_EXPECTED_TIME: f64 = 13.848; -const RTHK_BEEP_AUDIO_16K: &str = "sample_audios/test_16khz/rthk_section_with_beep_16k.wav"; -const CBS_NEWS_AUDIO_16K: &str = "sample_audios/test_16khz/cbs_news_audio_section_16k.wav"; -const CBS_NEWS_PATTERN_16K: &str = "sample_audios/test_16khz/clips/cbs_news_16k.wav"; +const RTHK_BEEP_AUDIO_16K: &str = "../../sample_audios/test_16khz/rthk_section_with_beep_16k.wav"; +const CBS_NEWS_AUDIO_16K: &str = "../../sample_audios/test_16khz/cbs_news_audio_section_16k.wav"; +const CBS_NEWS_PATTERN_16K: &str = "../../sample_audios/test_16khz/clips/cbs_news_16k.wav"; -const NONEXISTENT_PATTERN: &str = "sample_audios/clips/nonexistent.wav"; -const NONEXISTENT_AUDIO: &str = "sample_audios/nonexistent.wav"; +const NONEXISTENT_PATTERN: &str = "../../sample_audios/clips/nonexistent.wav"; +const NONEXISTENT_AUDIO: &str = "../../sample_audios/nonexistent.wav"; const NONEXISTENT_WAV: &str = "nonexistent.wav"; const SR: u32 = DEFAULT_TARGET_SAMPLE_RATE; @@ -826,39 +826,6 @@ mod test_wav_file_matching_without_ffmpeg { assert_eq!(matches(&peak_times, RAINBOW_INTRO_NAME).len(), 0); } - // Python mocked ffmpeg as unavailable. Here the CLI runs in a child process - // whose PATH is an empty directory, so ffmpeg cannot be found. - #[test] - fn test_wav_match_without_ffmpeg_available() { - assert_exists(RTHK_BEEP_PATTERN); - assert_exists(RTHK_BEEP_AUDIO); - - let empty_path = tempfile::tempdir().unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_audio-pattern-detector")) - .args(["match", RTHK_BEEP_AUDIO, "--pattern-file", RTHK_BEEP_PATTERN]) - .env("PATH", empty_path.path()) - .output() - .unwrap(); - assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); - - let events: Vec = String::from_utf8(output.stdout) - .unwrap() - .lines() - .map(|line| serde_json::from_str(line).unwrap()) - .collect(); - let detections: Vec<&serde_json::Value> = - events.iter().filter(|event| event["type"] == "pattern_detected").collect(); - for detection in &detections { - assert_eq!(detection["clip_name"], RTHK_BEEP_NAME); - } - let times: Vec = detections - .iter() - .map(|detection| detection["timestamp_ms"].as_f64().unwrap() / 1000.0) - .collect(); - - assert_times(×, &RTHK_BEEP_EXPECTED_TIMES, 0.01); - } - #[test] fn test_wav_match_streaming_with_wrapper() { assert_exists(RTHK_BEEP_PATTERN); diff --git a/tests/marker_tone_verification.rs b/crates/core/tests/marker_tone_verification.rs similarity index 94% rename from tests/marker_tone_verification.rs rename to crates/core/tests/marker_tone_verification.rs index 8caa4c4..02b7f55 100644 --- a/tests/marker_tone_verification.rs +++ b/crates/core/tests/marker_tone_verification.rs @@ -1,12 +1,12 @@ use std::path::Path; -use audio_pattern_detector::detector::{analyze_tone_candidate_context, marker_tone_accepts}; -use audio_pattern_detector::dsp::hanning; -use audio_pattern_detector::{ +use audio_pattern_detector_core::detector::{analyze_tone_candidate_context, marker_tone_accepts}; +use audio_pattern_detector_core::dsp::hanning; +use audio_pattern_detector_core::{ AudioClip, AudioPatternDetector, DetectorOptions, MarkerToneParams, Strategy, DEFAULT_TARGET_SAMPLE_RATE, }; -const RTHK_BEEP_PATTERN: &str = "sample_audios/clips/rthk_beep.apd.toml"; +const RTHK_BEEP_PATTERN: &str = "../../sample_audios/clips/rthk_beep.apd.toml"; const RTHK_BEEP_NAME: &str = "rthk_beep"; const HARMONIC_STACK_FUNDAMENTAL: f64 = 260.0; const HARMONIC_STACK_AMPLITUDES: [f32; 5] = [0.50, 0.35, 0.30, 0.28, 0.22]; diff --git a/tests/pattern_config.rs b/crates/core/tests/pattern_config.rs similarity index 98% rename from tests/pattern_config.rs rename to crates/core/tests/pattern_config.rs index 09c5773..1be6a62 100644 --- a/tests/pattern_config.rs +++ b/crates/core/tests/pattern_config.rs @@ -2,8 +2,8 @@ use std::path::{Path, PathBuf}; -use audio_pattern_detector::pattern_config::{load_apd_file, PatternConfig}; -use audio_pattern_detector::{MarkerToneParams, MarkerToneThresholds, Strategy, DEFAULT_TARGET_SAMPLE_RATE}; +use audio_pattern_detector_core::pattern_config::{load_apd_file, PatternConfig}; +use audio_pattern_detector_core::{MarkerToneParams, MarkerToneThresholds, Strategy, DEFAULT_TARGET_SAMPLE_RATE}; use base64::Engine; use tempfile::TempDir; diff --git a/tests/real_data_regressions.rs b/crates/core/tests/real_data_regressions.rs similarity index 79% rename from tests/real_data_regressions.rs rename to crates/core/tests/real_data_regressions.rs index 785cf53..280b8b4 100644 --- a/tests/real_data_regressions.rs +++ b/crates/core/tests/real_data_regressions.rs @@ -2,34 +2,34 @@ use std::path::Path; -use audio_pattern_detector::{match_pattern, MatchOptions}; +use audio_pattern_detector_core::{match_pattern, MatchOptions}; /// `(audio file, expected timestamps in seconds)`. type Case = (&'static str, &'static [f64]); -const RTHK_BEEP_PATTERN: &str = "sample_audios/clips/rthk_beep.apd.toml"; -const RADIO903_BEEP_PATTERN: &str = "sample_audios/clips/903_beep.apd.toml"; -const RADIO881_BEEP_PATTERN: &str = "sample_audios/clips/881_beep.apd.toml"; +const RTHK_BEEP_PATTERN: &str = "../../sample_audios/clips/rthk_beep.apd.toml"; +const RADIO903_BEEP_PATTERN: &str = "../../sample_audios/clips/903_beep.apd.toml"; +const RADIO881_BEEP_PATTERN: &str = "../../sample_audios/clips/881_beep.apd.toml"; const RTHK_BEEP_CLIP_NAME: &str = "rthk_beep"; const RADIO903_BEEP_CLIP_NAME: &str = "903_beep"; const RADIO881_BEEP_CLIP_NAME: &str = "881_beep"; const RTHK_BEEP_STRAY_CLIPS_V2_TRUE_POSITIVE_1: &str = - "sample_audios/regressions/rthk_beep_stray_clips_v2/tp_09-10_beep1.wav"; + "../../sample_audios/regressions/rthk_beep_stray_clips_v2/tp_09-10_beep1.wav"; const RTHK_BEEP_STRAY_CLIPS_V2_TRUE_POSITIVE_2: &str = - "sample_audios/regressions/rthk_beep_stray_clips_v2/tp_09-10_beep2.wav"; + "../../sample_audios/regressions/rthk_beep_stray_clips_v2/tp_09-10_beep2.wav"; const RTHK_BEEP_STRAY_CLIPS_V2_TRUE_POSITIVE_3: &str = - "sample_audios/regressions/rthk_beep_stray_clips_v2/tp_09-10_beep3.wav"; + "../../sample_audios/regressions/rthk_beep_stray_clips_v2/tp_09-10_beep3.wav"; const RTHK_BEEP_STRAY_CLIPS_V2_FALSE_POSITIVE_1: &str = - "sample_audios/regressions/rthk_beep_stray_clips_v2/v2_10-11_20m21s.wav"; + "../../sample_audios/regressions/rthk_beep_stray_clips_v2/v2_10-11_20m21s.wav"; const RTHK_BEEP_STRAY_CLIPS_V2_FALSE_POSITIVE_2: &str = - "sample_audios/regressions/rthk_beep_stray_clips_v2/v2_10-11_50m40s.wav"; + "../../sample_audios/regressions/rthk_beep_stray_clips_v2/v2_10-11_50m40s.wav"; const RTHK_BEEP_STRAY_CLIPS_V2_FALSE_POSITIVE_3: &str = - "sample_audios/regressions/rthk_beep_stray_clips_v2/v2_20-21_35m13s.wav"; + "../../sample_audios/regressions/rthk_beep_stray_clips_v2/v2_20-21_35m13s.wav"; const RTHK_BEEP_STRAY_CLIPS_V2_FALSE_POSITIVE_4: &str = - "sample_audios/regressions/rthk_beep_stray_clips_v2/v2_22-23_19m48s.wav"; + "../../sample_audios/regressions/rthk_beep_stray_clips_v2/v2_22-23_19m48s.wav"; const RTHK_BEEP_STRAY_CLIPS_V2_TRUE_POSITIVE_CASES: &[Case] = &[ (RTHK_BEEP_STRAY_CLIPS_V2_TRUE_POSITIVE_1, &[2.00525, 3.004875]), @@ -45,9 +45,9 @@ const RTHK_BEEP_STRAY_CLIPS_V2_FALSE_POSITIVE_CASES: &[Case] = &[ ]; const RTHK_BEEP_HOURLY_LEADIN_12_TO_13: &str = - "sample_audios/regressions/rthk_beep_hourly_leadins/radio1_2026-04-06_12_to_13_28m51_leadin.wav"; + "../../sample_audios/regressions/rthk_beep_hourly_leadins/radio1_2026-04-06_12_to_13_28m51_leadin.wav"; const RTHK_BEEP_HOURLY_LEADIN_17_TO_18: &str = - "sample_audios/regressions/rthk_beep_hourly_leadins/radio1_2026-04-06_17_to_18_59m01_leadin.wav"; + "../../sample_audios/regressions/rthk_beep_hourly_leadins/radio1_2026-04-06_17_to_18_59m01_leadin.wav"; const RTHK_BEEP_HOURLY_LEADIN_CASES: &[Case] = &[ (RTHK_BEEP_HOURLY_LEADIN_12_TO_13, &[1.0085, 2.0, 3.013125, 3.987875, 5.025125]), @@ -55,9 +55,9 @@ const RTHK_BEEP_HOURLY_LEADIN_CASES: &[Case] = &[ ]; const RTHK_BEEP_HOURLY_OPENING_12_TO_13: &str = - "sample_audios/regressions/rthk_beep_hourly_openings/radio1_2026-04-06_12_to_13_28m49_opening.wav"; + "../../sample_audios/regressions/rthk_beep_hourly_openings/radio1_2026-04-06_12_to_13_28m49_opening.wav"; const RTHK_BEEP_HOURLY_OPENING_17_TO_18: &str = - "sample_audios/regressions/rthk_beep_hourly_openings/radio1_2026-04-06_17_to_18_58m58_opening.wav"; + "../../sample_audios/regressions/rthk_beep_hourly_openings/radio1_2026-04-06_17_to_18_58m58_opening.wav"; const RTHK_BEEP_HOURLY_OPENING_CASES: &[Case] = &[ ( @@ -71,15 +71,15 @@ const RTHK_BEEP_HOURLY_OPENING_CASES: &[Case] = &[ ]; const RADIO903_BEEP_OPENING_RECOVERY: &str = - "sample_audios/regressions/903_beep_openings/radio903_2026-04-17_09_to_10_12s_opening.wav"; + "../../sample_audios/regressions/903_beep_openings/radio903_2026-04-17_09_to_10_12s_opening.wav"; const RADIO903_BEEP_OPENING_RECOVERY_15_TO_16: &str = - "sample_audios/regressions/903_beep_openings/radio903_2026-04-17_15_to_16_opening.wav"; + "../../sample_audios/regressions/903_beep_openings/radio903_2026-04-17_15_to_16_opening.wav"; const RADIO903_BEEP_OPENING_NEGATIVE: &str = - "sample_audios/regressions/903_beep_openings/radio903_2026-04-17_06_to_07_no_opening_beep.wav"; + "../../sample_audios/regressions/903_beep_openings/radio903_2026-04-17_06_to_07_no_opening_beep.wav"; const RADIO881_BEEP_OPENING_RECOVERY: &str = - "sample_audios/regressions/881_beep_openings/radio881_2026-04-16_10_to_11_10s_opening.wav"; + "../../sample_audios/regressions/881_beep_openings/radio881_2026-04-16_10_to_11_10s_opening.wav"; const RADIO881_BEEP_OPENING_RECOVERY_DIRTY: &str = - "sample_audios/regressions/881_beep_openings/radio881_2026-04-15_11_to_12_30m20s_opening.wav"; + "../../sample_audios/regressions/881_beep_openings/radio881_2026-04-15_11_to_12_30m20s_opening.wav"; const RADIO903_BEEP_OPENING_CASES: &[Case] = &[ (RADIO903_BEEP_OPENING_RECOVERY, &[12.163125]), diff --git a/tests/short_clip.rs b/crates/core/tests/short_clip.rs similarity index 95% rename from tests/short_clip.rs rename to crates/core/tests/short_clip.rs index f38bd17..2914b74 100644 --- a/tests/short_clip.rs +++ b/crates/core/tests/short_clip.rs @@ -7,11 +7,11 @@ mod common; use std::io::Cursor; -use audio_pattern_detector::detector::SHORT_CLIP_DURATION_THRESHOLD; -use audio_pattern_detector::dsp::hanning; -use audio_pattern_detector::stream::F32leSource; -use audio_pattern_detector::tone::get_pure_tone_frequency; -use audio_pattern_detector::{ +use audio_pattern_detector_core::detector::SHORT_CLIP_DURATION_THRESHOLD; +use audio_pattern_detector_core::dsp::hanning; +use audio_pattern_detector_core::stream::F32leSource; +use audio_pattern_detector_core::tone::get_pure_tone_frequency; +use audio_pattern_detector_core::{ AudioClip, AudioPatternDetector, AudioStream, DetectorOptions, MarkerToneParams, MarkerToneThresholds, Strategy, }; diff --git a/tests/sliding_window.rs b/crates/core/tests/sliding_window.rs similarity index 98% rename from tests/sliding_window.rs rename to crates/core/tests/sliding_window.rs index b056e11..079bc8c 100644 --- a/tests/sliding_window.rs +++ b/crates/core/tests/sliding_window.rs @@ -6,16 +6,16 @@ mod common; -use audio_pattern_detector::stream::WavFileSource; -use audio_pattern_detector::{ +use audio_pattern_detector_core::stream::WavFileSource; +use audio_pattern_detector_core::{ match_pattern, AudioClip, AudioPatternDetector, AudioStream, DetectorOptions, MatchOptions, PeakTimes, }; use common::{clip_from_samples, concat, insert_at, silence, sine_tone, stream_from_samples, SR}; -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 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_PATTERN: &str = "../../sample_audios/clips/rthk_beep.apd.toml"; +const RTHK_BEEP_AUDIO: &str = "../../sample_audios/rthk_section_with_beep.wav"; +const CBS_NEWS_PATTERN: &str = "../../sample_audios/clips/cbs_news.wav"; +const CBS_NEWS_AUDIO: &str = "../../sample_audios/cbs_news_audio_section.wav"; const BEEP_NAME: &str = "test_beep"; const BEEP_DURATION: f64 = 0.23; @@ -87,7 +87,7 @@ fn closest_to(times: &[f64], expected: f64) -> f64 { .expect("at least one detection") } -fn new_detector(clips: Vec, seconds_per_chunk: Option) -> audio_pattern_detector::Result { +fn new_detector(clips: Vec, seconds_per_chunk: Option) -> audio_pattern_detector_core::Result { AudioPatternDetector::new(clips, options(seconds_per_chunk)) } diff --git a/docs/development.md b/docs/development.md index bc3c2cf..8dadc59 100644 --- a/docs/development.md +++ b/docs/development.md @@ -3,7 +3,7 @@ ## Building ```shell -cargo build # debug build +cargo build # debug build of the whole workspace cargo build --release # optimized binary at target/release/audio-pattern-detector ``` @@ -19,32 +19,39 @@ cargo clippy --all-targets -- -D warnings cargo test ``` -Unit tests live next to the code in `src/`; integration tests in `tests/` run the detector and the CLI binary against the clips in `sample_audios/`. Run the tests in release mode (`cargo test --release`) if the FFT-heavy integration tests feel slow. +Unit tests live next to the code in each crate's `src/`. Integration tests in `crates/core/tests/` run the library, and `crates/cli/tests/` runs the CLI binary, against the clips in the repo-root `sample_audios/` (referenced as `../../sample_audios/...`, since cargo runs integration tests from the crate directory). Run the tests in release mode (`cargo test --release`) if the FFT-heavy integration tests feel slow. The Python bindings have their own checks, see [python.md](python.md): ```shell -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 ``` ## Code layout +The repo is a Cargo workspace with two crates: + +- `crates/core` — `audio-pattern-detector-core` (library `audio_pattern_detector_core`): the detector, audio I/O and the Python bindings. No CLI dependencies. +- `crates/cli` — `audio-pattern-detector`: the `audio-pattern-detector` binary, a thin clap/JSONL front end over the core crate. + +The version is shared through `[workspace.package]` in the root `Cargo.toml`. + | Path | Contents | |------|----------| -| `src/main.rs` | CLI (`match`, `show-config`) and JSONL output | -| `src/matching.rs` | High-level entry points: file, WAV stream and multiplexed stream matching | -| `src/detector.rs` | `AudioPatternDetector`: chunking, Step 1 correlation, Step 2 verification | -| `src/tone.rs` | Pure-tone analysis for the marker-tone strategy | -| `src/pattern_config.rs` | `.apd.toml` loader | -| `src/audio_clip.rs` | `AudioClip` and verification strategies | -| `src/stream.rs` | Audio sources (`SampleSource`): memory, raw float32, WAV file, WAV stream | -| `src/wav.rs` | WAV reading and writing | -| `src/ffmpeg.rs` | ffmpeg subprocess for non-WAV files | -| `src/python.rs` | Python bindings (PyO3), compiled only with the `python` feature; see [python.md](python.md) | -| `src/dsp/` | FFT cross-correlation, BS.1770 loudness, peak finding, resampling, Pearson correlation, spectra | - -The numerical routines in `src/dsp/` are implemented in the crate rather than pulled in as dependencies; they follow the semantics of the scipy/numpy/pyloudnorm functions the algorithm was originally tuned against (`scipy.signal.find_peaks`, `scipy.signal.resample`, `scipy.signal.correlate`, `numpy.hanning`, BS.1770 integrated loudness), so thresholds carry over unchanged. +| `crates/cli/src/main.rs` | CLI (`match`, `show-config`) and JSONL output | +| `crates/core/src/matching.rs` | High-level entry points: file, WAV stream and multiplexed stream matching | +| `crates/core/src/detector.rs` | `AudioPatternDetector`: chunking, Step 1 correlation, Step 2 verification | +| `crates/core/src/tone.rs` | Pure-tone analysis for the marker-tone strategy | +| `crates/core/src/pattern_config.rs` | `.apd.toml` loader | +| `crates/core/src/audio_clip.rs` | `AudioClip` and verification strategies | +| `crates/core/src/stream.rs` | Audio sources (`SampleSource`): memory, raw float32, WAV file, WAV stream | +| `crates/core/src/wav.rs` | WAV reading and writing | +| `crates/core/src/ffmpeg.rs` | ffmpeg subprocess for non-WAV files | +| `crates/core/src/python.rs` | Python bindings (PyO3), compiled only with the `python` feature; see [python.md](python.md) | +| `crates/core/src/dsp/` | FFT cross-correlation, BS.1770 loudness, peak finding, resampling, Pearson correlation, spectra | + +The numerical routines in `crates/core/src/dsp/` are implemented in the crate rather than pulled in as dependencies; they follow the semantics of the scipy/numpy/pyloudnorm functions the algorithm was originally tuned against (`scipy.signal.find_peaks`, `scipy.signal.resample`, `scipy.signal.correlate`, `numpy.hanning`, BS.1770 integrated loudness), so thresholds carry over unchanged. ## Debug output diff --git a/docs/pattern-matching.md b/docs/pattern-matching.md index 9564afa..605f386 100644 --- a/docs/pattern-matching.md +++ b/docs/pattern-matching.md @@ -162,7 +162,7 @@ maximum_min_flank_purity = 0.02 maximum_max_flank_purity = 0.14 ``` -The currently implemented tone strategy is `marker_tone`. The extension point is the `[verification].strategy` field — adding a new special handler means adding a new strategy name and wiring it in `src/audio_clip.rs` (`Strategy`), `src/pattern_config.rs` and `src/detector.rs`. +The currently implemented tone strategy is `marker_tone`. The extension point is the `[verification].strategy` field — adding a new special handler means adding a new strategy name and wiring it in `crates/core/src/audio_clip.rs` (`Strategy`), `crates/core/src/pattern_config.rs` and `crates/core/src/detector.rs`. When a clip's strategy is tone-based: 1. The dominant frequency is taken from `[verification].dominant_frequency_hz` if declared, else from `[clip].frequency_hz` for sine sources, else fallback-derived via `get_pure_tone_frequency()` from the loaded audio. diff --git a/docs/python.md b/docs/python.md index 8c95ff7..31b2d3b 100644 --- a/docs/python.md +++ b/docs/python.md @@ -1,6 +1,6 @@ # Python bindings -The crate ships [PyO3](https://pyo3.rs) bindings behind the `python` cargo feature, packaged with [maturin](https://www.maturin.rs) as the `audio-pattern-detector` wheel. The wheel contains only the compiled extension: no Python dependencies, and one `abi3` wheel per platform works on every Python >= 3.9. ffmpeg is needed on `PATH` only for non-WAV input files. +The core crate (`crates/core`) ships [PyO3](https://pyo3.rs) bindings behind the `python` cargo feature, packaged with [maturin](https://www.maturin.rs) as the `audio-pattern-detector` wheel. The wheel contains only the compiled extension: no Python dependencies, and one `abi3` wheel per platform works on every Python >= 3.9. ffmpeg is needed on `PATH` only for non-WAV input files. ## Installation @@ -9,7 +9,7 @@ Wheels (Linux x86_64 and arm64, macOS Apple Silicon, Windows x86_64) are attache ```toml # pyproject.toml (uv) [project] -dependencies = ["audio-pattern-detector==0.4.0"] +dependencies = ["audio-pattern-detector==0.4.1"] [[tool.uv.index]] name = "audio-pattern-detector" @@ -100,8 +100,8 @@ uvx maturin build --release -o tmp/wheels uvx maturin develop --release # Tests: against the installed module, or the extension built by cargo -cargo build --features python --lib +cargo build -p audio-pattern-detector-core --features python --lib python -m unittest tests.test_python_bindings ``` -The package version comes from `Cargo.toml`. Type stubs live in `audio_pattern_detector.pyi` at the repo root and are bundled into the wheel; update them together with `src/python.rs`. +The package version comes from the workspace `Cargo.toml` (`[workspace.package]`). Type stubs live in `audio_pattern_detector.pyi` at the repo root and are bundled into the wheel; update them together with `crates/core/src/python.rs`. `pyproject.toml` points maturin at the core crate (`manifest-path`) and keeps the Python module name `audio_pattern_detector` (`module-name`), although the Rust library is `audio_pattern_detector_core`. diff --git a/pyproject.toml b/pyproject.toml index fc7c38c..0521189 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,5 +12,7 @@ readme = "README.md" requires-python = ">=3.9" [tool.maturin] +manifest-path = "crates/core/Cargo.toml" +module-name = "audio_pattern_detector" bindings = "pyo3" features = ["python"] diff --git a/tests/test_python_bindings.py b/tests/test_python_bindings.py index 0b421e6..49e90f7 100644 --- a/tests/test_python_bindings.py +++ b/tests/test_python_bindings.py @@ -1,7 +1,7 @@ """Tests for the Python bindings. Run against an installed wheel (`maturin develop` or `pip install`), or -against the extension built with `cargo build --features python --lib`. +against the extension built with `cargo build -p audio-pattern-detector-core --features python --lib`. """ import importlib.machinery @@ -55,7 +55,7 @@ def _built_module_path() -> pathlib.Path: for profile in ("debug", "release"): target_dir = REPO_ROOT / "target" / profile - for stem in ("libaudio_pattern_detector", "audio_pattern_detector"): + for stem in ("libaudio_pattern_detector_core", "audio_pattern_detector_core"): for suffix in suffixes: path = target_dir / f"{stem}{suffix}" if path.exists(): @@ -63,7 +63,7 @@ def _built_module_path() -> pathlib.Path: raise FileNotFoundError( "Could not find the audio_pattern_detector extension. Install it with " - "`maturin develop` or build it with `cargo build --features python --lib`." + "`maturin develop` or build it with `cargo build -p audio-pattern-detector-core --features python --lib`." )