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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,17 @@ jobs:
# `ld: library 'clang_rt.osx' not found`. Selecting latest-stable makes
# the clang resource dir deterministic.
- if: runner.os == 'macOS'
id: xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
# Key the cache to the Xcode version: build-script outputs embed
# absolute toolchain paths, so a cache built under another Xcode
# poisons the link line (dead -L search paths, missing clang_rt).
key: xcode-${{ steps.xcode.outputs.version }}
- run: cargo check ${{ matrix.flags }} --locked

fmt:
Expand Down Expand Up @@ -72,13 +78,19 @@ jobs:
- uses: actions/checkout@v4
# See the `check` job: deterministic Xcode so macOS linking is stable.
- if: runner.os == 'macOS'
id: xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
# Key the cache to the Xcode version: build-script outputs embed
# absolute toolchain paths, so a cache built under another Xcode
# poisons the link line (dead -L search paths, missing clang_rt).
key: xcode-${{ steps.xcode.outputs.version }}
with:
key: clippy-${{ matrix.os }}-${{ hashFiles('Cargo.toml', '**/Cargo.toml', '**/Cargo.lock') }}
cache-on-failure: false
Expand Down Expand Up @@ -106,11 +118,17 @@ jobs:
- uses: actions/checkout@v4
# See the `check` job: deterministic Xcode so macOS linking is stable.
- if: runner.os == 'macOS'
id: xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
# Key the cache to the Xcode version: build-script outputs embed
# absolute toolchain paths, so a cache built under another Xcode
# poisons the link line (dead -L search paths, missing clang_rt).
key: xcode-${{ steps.xcode.outputs.version }}
- uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,17 @@ jobs:
# Deterministic Xcode on macOS (floating image sometimes lacks the clang
# runtime libclang_rt.osx.a → linker failure); keeps the wheel build stable.
- if: runner.os == 'macOS'
id: xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
# Key the cache to the Xcode version: build-script outputs embed
# absolute toolchain paths, so a cache built under another Xcode
# poisons the link line (dead -L search paths, missing clang_rt).
key: xcode-${{ steps.xcode.outputs.version }}
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,19 @@ jobs:
# default selection whose clang runtime (libclang_rt.osx.a) is missing,
# which would fail the link mid-release. Keep release builds reproducible.
- if: runner.os == 'macOS'
id: xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
# Key the cache to the Xcode version: build-script outputs embed
# absolute toolchain paths, so a cache built under another Xcode
# poisons the link line (dead -L search paths, missing clang_rt).
key: xcode-${{ steps.xcode.outputs.version }}
- run: cargo build --release --features cli --target ${{ matrix.target }}
- name: Rename binary (unix)
if: runner.os != 'Windows'
Expand Down Expand Up @@ -100,6 +106,7 @@ jobs:
- uses: actions/checkout@v4
# See cli-binaries: deterministic Xcode so macOS wheels link reproducibly.
- if: runner.os == 'macOS'
id: xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable
Expand All @@ -108,6 +115,11 @@ jobs:
with:
python-version: "3.12"
- uses: Swatinem/rust-cache@v2
with:
# Key the cache to the Xcode version: build-script outputs embed
# absolute toolchain paths, so a cache built under another Xcode
# poisons the link line (dead -L search paths, missing clang_rt).
key: xcode-${{ steps.xcode.outputs.version }}
- run: pip install maturin
- run: maturin build --release --out dist
- uses: actions/upload-artifact@v4
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **One EP-aware ONNX session builder.** `onnx::build_session_with_ep(path, ep,
intra_threads)` is now the single place embedding/segmentation `ort` sessions
are constructed: header validation always runs before ort parses the file,
and the requested `ExecutionProvider` is registered in one audited spot.
Selecting a provider that isn't wired yet (NNAPI/CUDA/XNNPACK, or CoreML
without the `coreml` feature) logs a warning and runs on CPU instead of
silently doing nothing — the first observable step toward making
`PipelineConfig.execution_provider` real. **Breaking:** the canonical
`ExecutionProvider` enum moved to `polyvoice::onnx` (still re-exported from
`pipeline_v2::config`), and `OnnxEmbeddingExtractor::new`,
`FbankOnnxExtractor::new`, and `PowersetSegmenter::with_config` gained an
`ep` parameter (`PowersetSegmenter::new` keeps its signature and picks the
target default). Default behavior is unchanged: CoreML on Apple Silicon for
segmentation/legacy embedding, plain CPU for the fbank embedder.
- **No-collar DER is now release-gated.** The DER regression tests (legacy and
pipeline-v2) score every run at collar 0 in addition to collar 0.25 and fail
when the no-collar DER exceeds its committed baseline + tolerance; on the
Expand Down
1 change: 1 addition & 0 deletions examples/hybrid_benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ fn main() {
sample_rate: 16000,
aggregation: Default::default(),
},
polyvoice::onnx::ExecutionProvider::auto(),
)
.expect("segmenter");
let pool_size = std::thread::available_parallelism()
Expand Down
9 changes: 7 additions & 2 deletions src/bin/polyvoice-bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,8 +301,13 @@ fn main() -> Result<()> {
anyhow::bail!("unknown --pipeline '{other}' (expected 'legacy' or 'v2')");
}
let embedding_dim = profile.embedding_dim();
let extractor = FbankOnnxExtractor::new(&models.embedder_path, embedding_dim, 1)
.context("load embedder")?;
let extractor = FbankOnnxExtractor::new(
&models.embedder_path,
embedding_dim,
1,
polyvoice::onnx::ExecutionProvider::Cpu,
)
.context("load embedder")?;
let vad_path = registry.ensure("silero_vad").context("silero_vad model")?;
let vad = SileroVad::new(&vad_path, 512).context("load vad")?;

Expand Down
9 changes: 7 additions & 2 deletions src/bin/polyvoice-mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,13 @@ fn run_diarize(input: &DiarizeInput) -> Result<DiarizationResult, ErrorData> {
let models = registry
.ensure_for_profile(profile)
.map_err(|e| err(ERR_MODEL_LOAD, e.to_string()))?;
let extractor = FbankOnnxExtractor::new(&models.embedder_path, profile.embedding_dim(), 1)
.map_err(|e| err(ERR_MODEL_LOAD, e.to_string()))?;
let extractor = FbankOnnxExtractor::new(
&models.embedder_path,
profile.embedding_dim(),
1,
polyvoice::onnx::ExecutionProvider::Cpu,
)
.map_err(|e| err(ERR_MODEL_LOAD, e.to_string()))?;
let vad_path = registry
.ensure("silero_vad")
.map_err(|e| err(ERR_MODEL_LOAD, e.to_string()))?;
Expand Down
9 changes: 7 additions & 2 deletions src/bin/polyvoice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,8 +261,13 @@ fn run_legacy_pipeline(
.ensure_for_profile(profile)
.context("ensure models")?;
let embedding_dim = profile.embedding_dim();
let extractor = FbankOnnxExtractor::new(&models.embedder_path, embedding_dim, 1)
.context("load embedder")?;
let extractor = FbankOnnxExtractor::new(
&models.embedder_path,
embedding_dim,
1,
polyvoice::onnx::ExecutionProvider::Cpu,
)
.context("load embedder")?;
let vad_path = registry.ensure("silero_vad").context("silero_vad model")?;
let mut vad = SileroVad::new(&vad_path, 512).context("load vad")?;

Expand Down
21 changes: 10 additions & 11 deletions src/ecapa/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,22 @@ pub struct FbankOnnxExtractor {
#[cfg(feature = "onnx")]
impl FbankOnnxExtractor {
/// { pool_size > 0 }
/// `fn new(model_path: &Path, embedding_dim: usize, pool_size: usize) -> Result<Self, anyhow::Error>`
/// `fn new(model_path: &Path, embedding_dim: usize, pool_size: usize, ep: ExecutionProvider) -> Result<Self, anyhow::Error>`
/// { ret.pool.len() == pool_size }
pub fn new(model_path: &Path, embedding_dim: usize, pool_size: usize) -> anyhow::Result<Self> {
pub fn new(
model_path: &Path,
embedding_dim: usize,
pool_size: usize,
ep: crate::onnx::ExecutionProvider,
) -> anyhow::Result<Self> {
if pool_size == 0 {
anyhow::bail!("pool_size must be > 0");
}
crate::onnx::validate_onnx_header(model_path)
.map_err(|e| EmbeddingError::InferenceFailed(e.to_string()))?;
let pool = crossbeam_queue::ArrayQueue::new(pool_size);
for i in 0..pool_size {
let session = ort::session::Session::builder()
.map_err(|e| EmbeddingError::InferenceFailed(e.to_string()))?
.with_intra_threads(1)
.map_err(|e| {
EmbeddingError::InferenceFailed(format!("session {i} intra threads: {e}"))
})?
.commit_from_file(model_path)
// intra_threads(1): this extractor parallelises across the session
// pool, so each session stays single-threaded.
let session = crate::onnx::build_session_with_ep(model_path, ep, Some(1))
.map_err(|e| EmbeddingError::InferenceFailed(format!("session {i}: {e}")))?;
pool.push(session)
.map_err(|_| anyhow::anyhow!("failed to push session into pool"))?;
Expand Down
32 changes: 22 additions & 10 deletions src/embedder/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,18 @@ mod onnx_adapters {
/// { ret.as_ref().map_or(true, |e| e.dim() == 256) }
/// Load the WeSpeaker ResNet34 ONNX model.
pub fn new(path: impl AsRef<Path>, pool_size: usize) -> Result<Self, EmbedderError> {
let inner = FbankOnnxExtractor::new(path.as_ref(), 256, pool_size).map_err(|e| {
EmbedderError::ModelIo {
path: path.as_ref().to_path_buf(),
detail: format!("{e}"),
}
// ExecutionProvider::Cpu preserves this path's historical behavior
// (it never registered an EP); threading the configured EP through
// is the follow-up that wires PipelineConfig.execution_provider.
let inner = FbankOnnxExtractor::new(
path.as_ref(),
256,
pool_size,
crate::onnx::ExecutionProvider::Cpu,
)
.map_err(|e| EmbedderError::ModelIo {
path: path.as_ref().to_path_buf(),
detail: format!("{e}"),
})?;
Ok(Self { inner, dim: 256 })
}
Expand Down Expand Up @@ -290,11 +297,16 @@ mod onnx_adapters {
dim: usize,
pool_size: usize,
) -> Result<Self, EmbedderError> {
let inner = FbankOnnxExtractor::new(path.as_ref(), dim, pool_size).map_err(|e| {
EmbedderError::ModelIo {
path: path.as_ref().to_path_buf(),
detail: format!("{e}"),
}
// Cpu: same historical-behavior note as ResNet34Adapter above.
let inner = FbankOnnxExtractor::new(
path.as_ref(),
dim,
pool_size,
crate::onnx::ExecutionProvider::Cpu,
)
.map_err(|e| EmbedderError::ModelIo {
path: path.as_ref().to_path_buf(),
detail: format!("{e}"),
})?;
Ok(Self { inner, dim })
}
Expand Down
24 changes: 24 additions & 0 deletions src/onnx/MODULE_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,30 @@ surface:
kind: unit-test
target: src/onnx::mod::tests
command: cargo test --lib onnx --features onnx
- name: ExecutionProvider
kind: enum
visibility: public
contract: >
Canonical execution-provider selector (Cpu/CoreMl/Nnapi/Cuda/XnnPack)
with a target-aware auto(). Lives here (the module that owns session
creation); pipeline_v2::config re-exports it.
proof:
kind: unit-test
target: src/onnx::mod::tests
command: cargo test --lib onnx --features onnx
- name: build_session_with_ep
kind: function
visibility: public
contract: >
THE single ort session constructor for embedding + segmentation paths.
Validates the ONNX header BEFORE ort parses the file, optionally pins
intra-op threads, then registers the requested EP. Unwired providers
warn (tracing) and fall back to CPU; EP registration failure is never
fatal (ort's CPU fallback keeps inference correct).
proof:
kind: unit-test
target: src/onnx::mod::tests
command: cargo test --lib onnx --features onnx
- name: OnnxValidationError
kind: struct
visibility: public
Expand Down
6 changes: 6 additions & 0 deletions src/onnx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ ONNX model validation (header checks) and legacy OnnxEmbeddingExtractor.

- `validate_onnx_header(path)`
- `OnnxValidationError`
- `ExecutionProvider` — canonical EP selector (re-exported by `pipeline_v2::config`)
- `build_session_with_ep(path, ep, intra_threads)` — the ONE ort-session
constructor for embedding + segmentation paths (validate-before-build,
EP registration, warn + CPU fallback for unwired providers)
- `OnnxEmbeddingExtractor` — legacy wrapper

## Dependencies
Expand All @@ -19,6 +23,8 @@ ONNX model validation (header checks) and legacy OnnxEmbeddingExtractor.
## Invariants

- validate_onnx_header rejects non-ONNX files and accepts valid ONNX files.
- EP selection lives here: every embedding/segmentation session is built via
build_session_with_ep, and validation always runs before ort parses a file.

## Verification

Expand Down
Loading
Loading