diff --git a/.gitignore b/.gitignore index cc44e7e..23b78f3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,6 @@ __pycache__/ # OS .DS_Store -# conduct skill state +# conduct / deep-review skill state (local, run-specific) .conduct/ +.deep-review/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dbafc6..cf69e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to `pipecat-local-stt-server` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-06-05 + +### Added + +- **Nemotron 3.5 ASR backend** (`stt_server/backends/nemotron.py`), selected + via `--backend nemotron` and installed with `uv sync --group nemotron`. + Default model `mlx-community/nemotron-3.5-asr-streaming-0.6b`. Like Parakeet, + it decodes from a temp WAV holding raw utterance audio (PII), written to a + per-process private `0o700` directory and unlinked after decode. + +### Notes + +- **Packaging — Option 1 landed: a git-pinned `[dependency-groups]` dev group, + not a published extra.** The backend requires Nemotron STT support from + `mlx-audio`, which only merged in PR #774 and is not yet in any published + `mlx-audio` PyPI release. A direct-URL (`@ git+…`) dependency is forbidden in + a published wheel's `Requires-Dist` (PyPI rejects direct-URL deps in extra + metadata), so shipping a `nemotron` *extra* would block 0.3.0 from PyPI + entirely. PEP 735 dependency groups are never emitted into wheel/sdist + metadata, so `uv sync --group nemotron` installs the git-pinned backend + locally while the published 0.3.0 stays PyPI-installable. PyPI-installability + was verified by confirming the built wheel's `METADATA` carries **no** + `mlx-audio` direct-URL in `Requires-Dist`. Promote to a versioned `nemotron` + extra once `mlx-audio` publishes a release containing #774. + ## [0.2.0] - 2026-05-30 ### Changed (BREAKING) @@ -132,6 +157,7 @@ import name `stt_server`. - Wire protocol is unchanged: `PROTOCOL_VERSION == "0.1"`; the `server.hello` and `server.status` shapes are stable. +[0.3.0]: https://github.com/vr000m/pipecat-local-stt-server/releases/tag/v0.3.0 [0.2.0]: https://github.com/vr000m/pipecat-local-stt-server/releases/tag/v0.2.0 [0.1.2]: https://github.com/vr000m/pipecat-local-stt-server/releases/tag/v0.1.2 [0.1.1]: https://github.com/vr000m/pipecat-local-stt-server/releases/tag/v0.1.1 diff --git a/README.md b/README.md index 8fdeb40..801c0fb 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,18 @@ monorepo. BSD-2-Clause. - backend interface (`TranscriptionBackend`) so MLX can be swapped later - `EchoBackend` reference implementation for tests and smoke-checks - `MLXWhisperBackend` shipped in `stt_server/backends/mlx_whisper.py` (requires - the `stt-server-mlx` extra) + the `mlx` extra) - `ParakeetBackend` shipped in `stt_server/backends/parakeet.py` (requires the - `stt-server-parakeet` extra; default model `mlx-community/parakeet-tdt-0.6b-v3`). + `parakeet` extra; default model `mlx-community/parakeet-tdt-0.6b-v3`). Parakeet decodes from a temp WAV; that WAV holds raw utterance audio (PII) and is written to a per-process private `0o700` directory (created at backend start, removed on `close()`), never the world-listable system temp dir. +- `NemotronBackend` shipped in `stt_server/backends/nemotron.py` (requires the + `nemotron` dev group — `uv sync --group nemotron`, not an extra; default model + `mlx-community/nemotron-3.5-asr-streaming-0.6b`). Nemotron decodes + from a temp WAV; that WAV holds raw utterance audio (PII) and is written to a + per-process private `0o700` directory (created at backend start, removed on + `close()`), never the world-listable system temp dir. ## Running the server @@ -37,7 +43,7 @@ monorepo. BSD-2-Clause. uv run python -m stt_server --socket-path ~/Library/Caches/pipecat-stt/stt.sock --backend echo # MLX Whisper over UDS -uv sync --extra stt-server-mlx +uv sync --extra mlx uv run python -m stt_server --socket-path ~/Library/Caches/pipecat-stt/stt.sock --backend mlx # Loopback TCP (use --auth-token-file or PIPECAT_STT_AUTH_TOKEN env — legacy @@ -61,7 +67,7 @@ below for one such integration. ## Multi-backend operation Each server process loads exactly **one** backend, pinned at launch via -`--backend {echo,mlx,parakeet}`. To run more than one ASR — for example to +`--backend {echo,mlx,parakeet,nemotron}`. To run more than one ASR — for example to A/B-benchmark Parakeet against Whisper — start a second server process on a **separate socket**. The V1 wire protocol is unchanged; the only difference between two ASRs from the bot's perspective is which socket it connects to. @@ -72,6 +78,7 @@ between two ASRs from the bot's perspective is which socket it connects to. |---|---|---|---| | whisper (`mlx`) | `pipecat.stt-server` | `~/Library/Caches/pipecat-stt/stt.sock` | leave `STT_WS_SOCKET` unset | | parakeet | `pipecat.stt-server.parakeet` | `~/Library/Caches/pipecat-stt/parakeet.sock` | set `STT_WS_SOCKET` to the parakeet socket | +| nemotron | `pipecat.stt-server.nemotron` | `~/Library/Caches/pipecat-stt/nemotron.sock` | set `STT_WS_SOCKET` to the nemotron socket | Whisper uses the default label and socket, so a bot-side default of `~/Library/Caches/pipecat-stt/stt.sock` resolves to it with no `.env` @@ -96,7 +103,7 @@ scripts/install_stt_agent.sh install # Warm the ~1.5 GB Hugging Face model cache FIRST: a cold first launch # downloads it under KeepAlive + ThrottleInterval=10 and launchd may # throttle-loop the agent before the download finishes. -uv sync --extra stt-server-parakeet +uv sync --extra parakeet .venv/bin/python -c 'import parakeet_mlx; parakeet_mlx.from_pretrained("mlx-community/parakeet-tdt-0.6b-v3")' PIPECAT_STT_LABEL=pipecat.stt-server.parakeet \ PIPECAT_STT_SOCKET="$HOME/Library/Caches/pipecat-stt/parakeet.sock" \ @@ -244,9 +251,8 @@ from a LaunchAgent keepalive script. The existing `--socket-path`/`--host`/ Only `TranscriptionClient` (plus `protocol`, `backend` interfaces, and `EchoBackend`) is re-exported from the package root — server runtime (`TranscriptionServer`, `ServerConfig`, `serve`) lives under -`stt_server.server`. This lets a client-only install (`stt-server-client` -extra) skip the `websockets.asyncio.server` dependency once the package is -extracted. +`stt_server.server`. This lets a client-only install (`client` extra) +skip the `websockets.asyncio.server` dependency. ```python from stt_server import TranscriptionClient @@ -284,15 +290,22 @@ socket — see "Multi-backend operation"); no client code changes. ```bash # Whisper (MLX) — default model mlx-community/whisper-large-v3-turbo -uv sync --extra stt-server-mlx +uv sync --extra mlx uv run python -m stt_server serve --backend mlx \ --socket-path ~/Library/Caches/pipecat-stt/stt.sock # Parakeet — default model mlx-community/parakeet-tdt-0.6b-v3 -uv sync --extra stt-server-parakeet +uv sync --extra parakeet uv run python -m stt_server serve --backend parakeet \ --socket-path ~/Library/Caches/pipecat-stt/parakeet.sock +# Nemotron 3.5 — default model mlx-community/nemotron-3.5-asr-streaming-0.6b. +# NOTE: installed via a dev GROUP, not an extra: `uv sync --group nemotron` +# (there is intentionally no `--extra nemotron`). +uv sync --group nemotron +uv run python -m stt_server serve --backend nemotron \ + --socket-path ~/Library/Caches/pipecat-stt/nemotron.sock + # Pick a specific model with --model (any compatible mlx-community HF repo id) uv run python -m stt_server serve --backend mlx \ --model mlx-community/whisper-small --socket-path .../stt.sock @@ -300,7 +313,27 @@ uv run python -m stt_server serve --backend mlx \ `--model` is passed through verbatim; an unset value uses the backend-aware default (the Whisper repo for `mlx`/`echo`, `parakeet-tdt-0.6b-v3` for -`parakeet`). Pointing a backend at a mismatched repo fails fast at decode. +`parakeet`, `nemotron-3.5-asr-streaming-0.6b` for `nemotron`). Pointing a +backend at a mismatched repo fails fast at decode. + +Nemotron ships behind a `[dependency-groups]` **dev group** rather than a +PyPI extra (`uv sync --group nemotron`, not `--extra nemotron`). The backend +needs Nemotron STT support from `mlx-audio`, which only landed in PR #774 — +not yet in any published `mlx-audio` release. A dev group therefore git-pins +the dependency directly: + +```bash +# Equivalent direct install of the git-pinned mlx-audio one-liner: +uv pip install "mlx-audio @ git+https://github.com/Blaizzy/mlx-audio" +``` + +It is a dev group on purpose: a direct-URL (`@ git+…`) dependency cannot be +emitted into a published wheel's `Requires-Dist` (PyPI rejects direct-URL +deps in published extra metadata), whereas PEP 735 dependency groups are +never written into wheel/sdist metadata at all. Keeping Nemotron in a dev +group lets `uv sync --group nemotron` install it locally while 0.3.0 stays +PyPI-clean. Once `mlx-audio` cuts a PyPI release containing #774, this can +be promoted to a versioned `nemotron` extra. Common MLX Whisper models (smaller = faster + lower RAM, larger = more accurate). These are `mlx-community` Hugging Face repos; the first launch @@ -315,6 +348,7 @@ downloads and caches the weights. | `mlx` | `mlx-community/whisper-base` | fast, lower accuracy | | `mlx` | `mlx-community/whisper-tiny` | fastest, lowest accuracy | | `parakeet` | `mlx-community/parakeet-tdt-0.6b-v3` | **default** Parakeet TDT | +| `nemotron` | `mlx-community/nemotron-3.5-asr-streaming-0.6b` | **default** Nemotron 3.5 ASR (dev group — `uv sync --group nemotron`) | Any `mlx-community` Whisper repo (e.g. `…-large-v3-turbo-q4` quantised variants, or `…-large-v3-turbo` language-specialised forks) works as a diff --git a/docs/dev_plans/20260605-nemotron-asr-backend.md b/docs/dev_plans/20260605-nemotron-asr-backend.md new file mode 100644 index 0000000..e1be866 --- /dev/null +++ b/docs/dev_plans/20260605-nemotron-asr-backend.md @@ -0,0 +1,516 @@ +# Task: Add NVIDIA Nemotron 3.5 ASR streaming backend (0.3.0) + +**Status**: Implemented — PR #7 open / in review (all phases 0-3 complete 2026-06-05) +**Component**: ASR Backends +**Assigned to**: Claude +**Priority**: Medium +**Branch**: feat/nemotron-asr-backend +**Created**: 2026-06-05 + +## Objective + +Add a fourth ASR backend, `nemotron`, backed by NVIDIA's +**Nemotron 3.5 ASR streaming 0.6b** (`mlx-community/nemotron-3.5-asr-streaming-0.6b`) +running on Apple Silicon via the **`mlx-audio`** package. Wire it into the +existing single-backend-per-process model exactly like `parakeet`: a new +`--backend nemotron` choice, a backend-aware default model, a temporary +`[dependency-groups]` local-install path until a PyPI-clean extra is possible, +a private-temp-dir commit-oriented decode, and the per-ASR socket +convention. Ship as a **0.3.0** minor (additive, non-breaking). + +## Context + +Nemotron 3.5 ASR is a **cache-aware FastConformer-RNNT**. Point-in-time model +card / PR facts to re-check during Phase 0: 600M params, 24 encoder layers, 40 +language-locales via language-ID prompt conditioning, and streaming chunk sizes +around 80 ms-1.12 s. It is distinct from Parakeet TDT and is **NOT** served by +`parakeet-mlx` — +`parakeet_mlx.from_pretrained` only returns Parakeet variants +(`ParakeetTDT/RNNT/CTC/TDTCTC`). The MLX runtime for Nemotron is +**`mlx-audio`** (Blaizzy/mlx-audio), API: + +```python +from mlx_audio.stt import load +model = load("mlx-community/nemotron-3.5-asr-streaming-0.6b") +result = model.generate("audio.wav", language="en-US") # offline, full-file +text = result.text +# (also exposes model.stream_generate(path, language=...) yielding cumulative +# AlignedResult per chunk — NOT used in V1; see "Streaming deferred" below.) +``` + +> **ASSUMPTION — verify at integration (the whole `mlx_audio` surface, not just +> `generate()`).** `mlx_audio` is NOT in this repo (`rg mlx_audio` hits only this +> plan), so the entire API shape above is inference from the model card / PR #774, +> not verified code: the `mlx_audio.stt` module path, the `load` entry point and +> its arity, `generate()`'s signature (kwargs, path-vs-array input), and the +> `result.text` / `AlignedResult` return shape. Treat **all** of these as +> integration-time checks — a wrong module path, kwarg, or attribute passes the +> stubbed CI but fails live. Implementation MUST mirror parakeet's defensive +> `getattr(result, "text", "") or ""` (`parakeet.py:158`) rather than asserting +> `result.text`, so an unexpected return shape degrades to empty text, not an +> `AttributeError`. + +Because V1's wire is **commit-oriented** (buffer PCM → one decode → one `delta` ++ one `completed`), this backend mirrors `ParakeetBackend` almost exactly: +path-based decode (`generate()` is **assumed** to take a **file path**, not a raw +array — same as parakeet, unlike `mlx_whisper` which takes an array; part of the +integration-time check above), temp WAV in a per-process `0o700` dir, the asyncio ++ threading decode-lock pair, the in-flight drain for SIGTERM-mid-decode +Metal-command-buffer isolation, and the empty-decode contract (`delta` only on +non-empty text, `completed` always). + +> **ASSUMPTION — Metal crash-class parity.** The in-flight drain is carried over +> on the premise that Nemotron is exposed to the *same* SIGTERM-mid-decode Metal +> command-buffer assertion as parakeet/mlx. That rationale is grounded in readable +> code for parakeet-mlx (`parakeet.py:198-200`) but is an **inference** for +> mlx-audio (different package, FastConformer-RNNT vs TDT, decode path unreadable +> here). It is MLX/Metal-backed, so the assumption is plausible and the drain is a +> harmless no-op if wrong — keep it, but label the rationale as *assumed-by-analogy* +> in the module comment, not "identical", and confirm the failure mode at integration. + +**One material difference from Parakeet:** Nemotron's `generate()` **accepts a +`language` parameter** (target-lang prompting, e.g. `"en-US"`, or `"auto"` for +LID). Parakeet ignores the client `language`; Nemotron forwards it. See +"Language handling" below. + +### Dependency reality (BLOCKER — read before implementing) + +`mlx-audio` Nemotron STT support landed in +[Blaizzy/mlx-audio#774](https://github.com/Blaizzy/mlx-audio/pull/774), merged +to `main` ~2026-06-05. The **PyPI** `mlx-audio` release has **not** been cut +since ~end of April 2026, so **no released version on PyPI contains Nemotron +support**. + +> **POINT-IN-TIME FACTS — re-verify before pinning (as of 2026-06-05).** The +> PR-#774 merge state, the merge commit SHA the dev group will pin, and the +> "no PyPI release since ~end of April" claim are external facts that may drift +> before implementation. Re-check #774's merged SHA and `pip index versions +> mlx-audio` (or PyPI) immediately before writing the pin; if a release now +> contains #774, prefer a clean version pin over the git SHA. + +Consequences: + +- A `git+https://github.com/Blaizzy/mlx-audio` pin works for local + `uv sync`/source installs but **cannot** be declared in a PyPI-published + package's extra metadata — PyPI rejects projects whose `Requires-Dist` + carries direct-URL (PEP 508 `@ `) dependencies (see the PyPI + "Forbidden: direct dependencies" upload error / `pypa/packaging-problems`). + If this policy claim becomes load-bearing for a changed option, verify it + against Warehouse/PyPI or TestPyPI before altering the dependency strategy. +- Until `mlx-audio` cuts a release with PR #774, this backend **cannot ship a + clean PyPI extra**. **DECIDED (2026-06-05): Option 1 — dev dependency-group, + git-pinned.** + 1. **[CHOSEN] Wait** — hold the published `pyproject` *extra* (and a + PyPI-publishable 0.3.0) until `mlx-audio` releases; land backend code + + tests now behind a git-pinned **`[dependency-groups]` dev group** so it's + runnable locally (`uv sync --group nemotron`) but absent from published + `[project.optional-dependencies]`, keeping 0.3.0 PyPI-installable. A + follow-up adds the real `nemotron` extra (clean version pin) once + `mlx-audio` releases. + 2. ~~Git-pin the extra now~~ — rejected: would block publishing 0.3.0 to PyPI. + 3. ~~Pin a future version optimistically~~ — rejected: uninstallable extra, + no git fallback. + +Tests stub `mlx_audio` entirely (per the parakeet pattern) so CI never needs +the real package and the dependency situation does not block landing the code. +The dev group is a developer/local-install convenience; document `uv sync +--group nemotron` (or a `git+https` one-liner) in the README install block. + +### Pre-existing discrepancy — FIXED (2026-06-05, this branch) + +The README documented `uv sync --extra stt-server-mlx` / `--extra +stt-server-parakeet` but `pyproject.toml` names the extras **`mlx`** / +**`parakeet`** — so those documented commands **failed** (`uv` resolves extras +by their pyproject key). This was leftover monorepo-extraction drift +(`stt_server/__init__.py` even said "extras split once extracted"). + +**Resolved in this branch (commit "docs: fix stale extra names"):** corrected +the README executable commands to `--extra mlx` / `--extra parakeet`, plus the +prose and the stray `stt-server-{mlx,parakeet,client}` references in +`__init__.py`, `client.py`, `__main__.py`, `parakeet.py`, `mlx_teardown_spike.sh`, +and `test_stt_server.py`. Direction chosen: **fix docs to match the shipped +pyproject** (non-breaking) rather than rename the extras (which would break +consumers like Koda that already `uv sync --extra parakeet`). Launchd *labels* +`pipecat.stt-server.parakeet` were left untouched (they are not extras). +Verified `rg 'stt-server-(mlx|parakeet|client)'` returns only label hits. + +The Nemotron work therefore uses the correct convention from the start: a +`[dependency-groups]` **`nemotron`** dev group now (per the Option-1 decision +above), and a future `nemotron` *extra* with the same key once `mlx-audio` +releases — never a `stt-server-`-prefixed name. + +## Requirements + +- New `stt_server/backends/nemotron.py` with `NemotronBackend` + + `_NemotronStream`, structurally satisfying `TranscriptionBackend` / + `BackendStream`, mirroring `ParakeetBackend`: + - lazy `mlx_audio` imports only inside `start()` / `_get_model()` (never at + module load — preserves the lean-base invariant so `echo`/`mlx`/`parakeet` + construct without `mlx-audio` installed); + - `start()` does an eager **non-loading** STT entrypoint check (for example + `from mlx_audio.stt import load`) so the server fails before socket bind if + the installed package lacks the required module/callable, but does **not** + load the model; + - `_get_model()` lazy-loads via the Phase-0-verified `load(self._model_id)` under + `_model_lock` (load-once); + - commit-oriented decode in a daemon thread; PCM16LE buffer → temp WAV in a + per-process `tempfile.mkdtemp(prefix="pipecat-stt-nemotron-")` `0o700` dir, + written with the protocol-pinned `AUDIO_CHANNELS` / `AUDIO_SAMPLE_WIDTH_BYTES` + / `AUDIO_SAMPLE_RATE_HZ`; `os.unlink` in `finally`; dir removed in `close()`; + - asyncio `_decode_lock` + backend-scope threading `_thread_lock` pair, model + loaded **inside** the thread lock (same Metal-safety rationale as parakeet); + - backend-scope in-flight counter + condition + `close()` drain (3.0 s + bounded wait, warn-on-timeout) — identical Metal SIGTERM rationale; + - empty-decode contract: `delta` only when `result.text.strip()` is non-empty, + `completed` always; `cancel()` before `end()` yields no events; `cancel()` + mid-decode is bounded/crash-free; + - `backend_name = "nemotron"`; `self.model = model` for the + `server.hello`/`server.status` `backend.name`/`.model` identity fields. +- `DEFAULT_NEMOTRON_MODEL = "mlx-community/nemotron-3.5-asr-streaming-0.6b"` + exported from `backends/nemotron.py` (single source of truth, imported lazily + by `__main__._resolve_model`, never hardcoded twice — mirrors + `DEFAULT_PARAKEET_MODEL`). +- `stt_server/__main__.py`: + - `_make_backend`: add `name == "nemotron"` arm → lazy `from + .backends.nemotron import NemotronBackend` → `NemotronBackend(model=model)`; + - `_resolve_model`: add `backend == "nemotron"` arm → lazy-import + `DEFAULT_NEMOTRON_MODEL`; + - `--backend` choices `("echo", "mlx", "parakeet")` → + `("echo", "mlx", "parakeet", "nemotron")`; update the adjacent comment that + enumerates per-backend defaults. +- **Language handling — OPEN DESIGN QUESTION (resolve at integration).** + Nemotron `generate()` accepts `language` (target-lang prompting; the model + card cites `target_lang` values like `"en-US"` and `"auto"` for LID). The + protocol already plumbs the client `language` to `open_stream(language=…)`, + so a client-supplied value is **forwarded** to `generate(...)` (unlike + Parakeet, which ignores it). The unresolved part is **the default when the + client sends `None`**. Three candidates, with trade-offs: + + | Default | Pro | Con | + |----------------|-----|-----| + | **Omit kwarg** | Most conservative; asserts no value we can't verify against the real signature; model uses its own built-in default. | That built-in default is undocumented — could itself be LID or could be `en`; we'd be deferring, not deciding. | + | **`"auto"` (LID)** | Uses Nemotron's headline 40-locale language-ID; "right" for a multilingual local server; matches the model card's advertised mode. | Assumes `"auto"` is the *exact* accepted token (unverified until integration); LID adds latency and can mis-route short/silent utterances → wrong-language garbage. | + | **Explicit `"en"` / `"en-US"`** | Deterministic, lowest-latency for the dominant local use case; no LID mis-fire on short utterances. | Wrong kwarg *value* form unknown (`"en"` vs `"en-US"` vs ISO `"eng"`); silently wrong for non-English speakers; bakes an English assumption into a "local STT server" that Parakeet's `-v3` multilingual default does not.| + + **Recommendation to carry into implementation:** make the `None`-default a + single named module constant (e.g. `DEFAULT_NEMOTRON_LANGUAGE`) so the choice + is one-line-swappable, and **default it to `"auto"`** *iff* integration + confirms `"auto"` is accepted and LID latency is acceptable on the target + hardware; otherwise fall back to **omit-the-kwarg**. Do NOT hardcode `"en"` + for a server that otherwise ships a multilingual default — but record `"en-US"` + as the escape hatch if LID proves unreliable in practice. **All three forms + pass the stubbed tests**, so the exact accepted kwarg name (`language` vs + `target_lang`) and value vocabulary (`"auto"`/`"en-US"`/…) and the + `strip_lang_tags` output-cleanup flag are **integration-time verifications + against the real post-#774 `generate()` signature** — a wrong name/value + passes CI but fails live. The module docstring must state the **three-way + `language` contract across backends** so the divergence is discoverable, not + just the parakeet contrast: `parakeet` accepts-and-ignores + (`parakeet.py:62-68`), `mlx_whisper` forwards (`mlx_whisper.py:141`), and + `nemotron` forwards with a `DEFAULT_NEMOTRON_LANGUAGE` fallback. No shared + abstraction/registry is introduced — deliberately; the named constant is the + minimal call for three backends. +- `pyproject.toml`: + - version `0.2.0` → `0.3.0`; + - **Option 1 (decided):** add a git-pinned `mlx-audio` to a + `[dependency-groups]` **`nemotron`** dev group (NOT + `[project.optional-dependencies]`), so published extras stay PyPI-clean. + Pin to the merge SHA of PR #774 (or `@main`), e.g. + `mlx-audio @ git+https://github.com/Blaizzy/mlx-audio.git@`. **Do not** + add a `nemotron` *extra* or an unresolvable version pin yet — that follow-up + lands when `mlx-audio` publishes a release containing #774. + - **Verify PyPI-cleanliness with a concrete check, don't assume it.** PEP 735 + `[dependency-groups]` are not emitted into wheel/sdist `Requires-Dist`, but + confirm: `uv build && unzip -p dist/*.whl '*/METADATA' | grep -i mlx-audio` + must return **empty**. Add this to the packaging validation. +- Docs (same pass as code, per workflow): + - README: backend bullet (mirror the parakeet bullet incl. the PII/temp-WAV + note), `--backend {echo,mlx,parakeet,nemotron}` everywhere it is enumerated, + a per-ASR socket-convention table row + (`pipecat.stt-server.nemotron` / `~/Library/Caches/pipecat-stt/nemotron.sock`), + an install/smoke block mirroring parakeet's (l.99-103) but using + `uv sync --group nemotron` (dev group, not an extra — per Option 1); + the stale `stt-server-mlx`/`stt-server-parakeet` extra-command fix is + **already done on this branch** (see the FIXED note above); + - CHANGELOG `[0.3.0]` "Added" entry; footer link target; explicitly state + Option 1 landed, why no published `nemotron` extra is included yet, and how + PyPI-installability was verified. Update the PR description with the same + dependency/publishability decision and final validation evidence. + - **`scripts/render_stt_plist.py` `_BACKEND_RE` allowlist (MANDATORY, not an + audit).** `_BACKEND_RE = re.compile(r"^(echo|mlx|parakeet)$")` + (`render_stt_plist.py:44`) is a hard `sys.exit(2)` gate at `:102` — a + `nemotron` render/install is **rejected** until it is widened to + `^(echo|mlx|parakeet|nemotron)$`. Land this **in lockstep, one commit**, with + its parametrized test: extend `tests/test_render_stt_plist.py` (parametrize + `:255`) and add a `BACKEND=nemotron` allowlist-pass case mirroring the + `parakeet` / `bogus` cases (`:12`). **`_log_basename` needs NO nemotron + branch** — `parakeet` is not special-cased; the generic `.`→`-` fallthrough + (`render_stt_plist.py:48-68`) already yields `pipecat-stt-server-nemotron`, + so do **not** add a two-literal branch here (the 0.2.0 `_log_basename` rule + does not apply to nemotron). `_MODEL_RE` is generic and needs no change. + - **`scripts/install_stt_agent.sh` `DEFAULT_MODEL` branch (MANDATORY — silent + misconfig otherwise).** The script selects the default model by backend: + `if [[ "$BACKEND" == "parakeet" ]] … else ` (`install_stt_agent.sh:57-67`). + With `nemotron` it falls to the `else` and installs a **Whisper** repo id for + a Nemotron agent — a silent wrong-model install, not a fast failure. Add a + `nemotron` arm pointing at `DEFAULT_NEMOTRON_MODEL`'s value (mirror the + existing "must agree with `DEFAULT_PARAKEET_MODEL`" comment), and update the + header-comment backend enumeration (`echo|mlx|parakeet` → `…|nemotron`). + This is a **model-default** fix, not a label/socket one. +- Tests — the "mirror point-for-point" claim is binding, so the list below + enumerates **every** `tests/test_parakeet_backend.py` case (audited against the + real file), not a subset: + - `tests/test_nemotron_backend.py` mirroring `tests/test_parakeet_backend.py` + (stub `mlx_audio` via `sys.modules` injection before import; synthetic + PCM16LE, no binary fixtures): protocol conformance; **identity** — + `backend_name == "nemotron"` and `model` set, the fields that feed + `server.hello`/`server.status` (mirrors `test_backend_exposes_identity`, + `test_parakeet_backend.py:203`); non-empty → `delta`+`completed`; empty → + `completed` only; **whitespace-only decode → `completed` only, no `delta`** + (mirrors `test_whitespace_only_decode_yields_completed_only`, + `test_parakeet_backend.py:277`; directly exercises the `result.text.strip()` + requirement above); mid-decode raise propagates (no swallowed `completed`); + **no `kind="failed"` event invented** — source-grep guard mirroring + `test_no_failed_event_kind_defined` (`test_parakeet_backend.py:210`); + model-load failure raises from its distinct call site; `cancel()` pre-`end()` + → no events; `cancel()` mid-decode bounded; 60 s+ utterance not truncated; + overlapping decodes serialize; `DEFAULT_NEMOTRON_MODEL` non-empty. + - **Language tests split into two** (the `None`-default is an open question — + do NOT couple a deterministic test to an undecided value): + - **(a) forwarding (deterministic, choice-independent):** a client-supplied + `language` (e.g. `"es-ES"`) reaches the stub `generate`'s `language` kwarg. + Asserts the forwarded-not-ignored contract regardless of the default. + - **(b) `None`-default (gated on the constant):** with the client sending + `None`, assert the stub `generate` receives exactly what + `DEFAULT_NEMOTRON_LANGUAGE` resolves to (or that the kwarg is omitted, if + the constant encodes "omit"). The expected value is settled when the + constant is, not at plan time — the test pins whatever the constant says. + - **Lean-base no-import (subprocess):** + `test_make_backend_and_resolve_model_nemotron_do_not_import_mlx_audio` + mirroring `test_make_backend_parakeet_arm_does_not_import_parakeet_mlx` + (`test_stt_server.py:973`) — block `mlx_audio` import in a clean subprocess + and assert both `_make_backend("nemotron", …)` and + `_resolve_model("nemotron", None)` work without importing it. Without this + the lean-base `mlx_audio` claim is asserted but unproven. (Phase 2.) + - **CLI choice enumeration (executable, not just grep):** + `test_argparse_backend_choices_include_nemotron` + + `test_argparse_rejects_unknown_backend` parity, mirroring + `test_stt_server.py:921,929`. This is the executable proof of CLI + enumeration completeness; reserve grep for docs/scripts. (Phase 2.) + - **Plist allowlist:** a `BACKEND=nemotron` allowlist-pass case in + `tests/test_render_stt_plist.py` mirroring the existing `parakeet` / `bogus` + cases (`test_render_stt_plist.py:12`, parametrize `:255`). (Phase 2 — see the + `_BACKEND_RE` task.) + - **Installer model default:** add a `tests/test_install_migration.py` case + that runs the real installer harness with `PIPECAT_STT_BACKEND=nemotron` + and asserts the rendered plist/model path uses `DEFAULT_NEMOTRON_MODEL`, not + the Whisper default. This pins the silent-misconfig regression at the shell + boundary, not just in renderer unit tests. + - **Nemotron PII/temp-dir + shutdown invariants:** add focused + `tests/test_nemotron_backend.py` cases for owner-only temp-dir permissions, + temp WAV creation under that private dir plus `os.unlink` after decode, and + `close()` waiting/bounding while a decode is in flight. Keep these alongside + the existing event/serialization parity tests so the Metal-safety and PII + claims are executable, not comment-only. + - Any other test that enumerates backend choices — grep for `"parakeet"` / + `choices` and extend. +- `ruff format` + `ruff check` clean; **full** suite green before PR. + +## Review Focus + +- **Right runtime package**: confirm `mlx-audio` (`from mlx_audio.stt import + load`), NOT `parakeet-mlx`, and that the real (post-#774) `generate()` + signature matches what the backend calls — the stub will pass CI regardless, + so this is an **integration-time** verification, called out, not assumed. + This probe is Phase 0 and blocks backend implementation. +- **Dependency/publishability**: the chosen option (1/2/3) must keep — or + consciously forgo — PyPI-installability of 0.3.0; no unresolvable pin shipped. + State which option landed and why in the CHANGELOG/PR. +- **Lean-base invariant intact**: `echo`/`mlx`/`parakeet` still construct with + `mlx-audio` absent; the `nemotron` import is lazy in BOTH `_make_backend` and + `_resolve_model`; no module-load-time `import mlx_audio`. **Proven** by the new + subprocess test that blocks `mlx_audio` and calls both + `_make_backend("nemotron", ...)` and `_resolve_model("nemotron", None)` + (mirrors `test_stt_server.py:973`) — not merely asserted. +- **Metal-safety parity**: lock pair, model-load-inside-thread-lock, in-flight + drain, and `0o700` temp dir are present; the drain's crash-class rationale is + labelled *assumed-by-analogy* for mlx-audio (not "identical"), per the Metal + parity ASSUMPTION in Context. PII temp-WAV handling matches and is proven by + tests for private-dir permissions, temp WAV unlinking, and bounded `close()` + drain under an in-flight decode. +- **Empty-decode + cancel contract** byte-identical to parakeet/mlx semantics; + `result.text` read defensively via `getattr(result, "text", "") or ""`. +- **Language contract**: forwarded-not-ignored is proven by the deterministic + forwarding test (a); the `None` default is gated on `DEFAULT_NEMOTRON_LANGUAGE` + and pinned by test (b); the module docstring states the **three-way** contract + (parakeet ignores / mlx_whisper forwards / nemotron forwards-with-default). +- **Enumeration completeness**: every `{echo,mlx,parakeet}` site gains + `nemotron` — CLI choices (`__main__.py:350`), README prose+tables, **the + `_BACKEND_RE` allowlist gate** (`render_stt_plist.py:44`) and **the + install-script `DEFAULT_MODEL` branch** (`install_stt_agent.sh:57-67`) — the + two hard gates, not just docs. Grep-verified zero stragglers; the CLI half is + proven by `test_argparse_backend_choices_include_nemotron`, the renderer by + `tests/test_render_stt_plist.py`, and the installer default-model shell seam by + `tests/test_install_migration.py`. Sibling dev plans referencing backend + enumeration checked and updated if needed. +- **Streaming deferred is explicit**: the module docstring states V1 uses + `generate()` (offline) and that `stream_generate()` is intentionally unused + until a streaming wire protocol lands — so a reader doesn't assume the + cache-aware streaming is active. + +## Streaming deferred + +Nemotron's headline feature is cache-aware **streaming** (model-card / PR +point-in-time claim: 80 ms-1.12 s chunks; re-check in Phase 0). +V1's wire is commit-oriented and runs ASR **after** smart-turn, so the backend +always sees a complete utterance — identical to why Parakeet streaming is +deferred. We call `generate()` (full-file offline), not `stream_generate()`. +Activating streaming is a separate, larger change (new wire events, partial +`delta`s, VAD/turn interplay) tracked outside this plan. + +## Implementation Checklist + +### Phase 0: Dependency pin + real mlx-audio API verification + +**Impl files:** `pyproject.toml`, `uv.lock` +**Test files:** none, but record command output in the PR description / plan +workspace if needed +**Validation commands:** re-check #774 merge state and PyPI releases; add/update +the `nemotron` dependency group; run `uv lock` or `uv sync --group nemotron`; +then run a real-package probe against the pinned dependency. +**Validation:** the dependency pin is resolved and locked; `from mlx_audio.stt +import load` succeeds; the model loads or the probe explicitly records why a +full load is not possible on the machine; the callable used by the backend is +confirmed (`generate` kwarg name, file-path input, return text shape, language +value vocabulary including `"auto"` / `"en-US"` / omit behavior, and any +`strip_lang_tags` cleanup flag); the final `DEFAULT_NEMOTRON_LANGUAGE` decision +is recorded before tests are written. If a PyPI release now contains PR #774, +prefer a clean version pin over the git SHA and reconsider whether the follow-up +published extra can land in this same release. + +Phase 0 is a blocking integration gate. Do not implement `nemotron.py` or its +language tests until the real package probe has settled the call shape and the +default-language behavior; stubbed tests can make the wrong API look green. + +### Phase 1: NemotronBackend + tests (stubbed mlx_audio) + +**Impl files:** `stt_server/backends/nemotron.py` +**Test files:** `tests/test_nemotron_backend.py` +**Test command:** `uv run python -m pytest tests/test_nemotron_backend.py -q` +**Validation:** backend satisfies protocols; full parakeet-parity contract green +with `mlx_audio` fully stubbed; language-forwarding assertion green; private +temp-dir permissions, temp WAV unlinking, and bounded close-drain tests green. + +Mirror `stt_server/backends/parakeet.py` structurally; swap the runtime +(`from mlx_audio.stt import load` in `start()` / `_get_model()` + +Phase-0-verified `model.generate(path, language=…)` or omit-language variant), +the `backend_name`, the default-model constant, the temp-dir prefix, and the +language-forwarding behavior. Comments describe Nemotron's own rationale, not +parakeet's. + +### Phase 2: CLI wiring + choice-enumeration tests + +**Impl files:** `stt_server/__main__.py`, `scripts/render_stt_plist.py` +(**`_BACKEND_RE` — mandatory**), `scripts/install_stt_agent.sh` +(**`DEFAULT_MODEL` nemotron arm — mandatory**) +**Test files:** `tests/test_stt_server.py`, `tests/test_render_stt_plist.py`, +`tests/test_install_migration.py` (+ any enumerating backend choices) +**Test command:** `uv run python -m pytest tests/test_stt_server.py tests/test_render_stt_plist.py tests/test_install_migration.py -q` +**New tests (mandatory, land in the same commit as the choice-tuple change):** +`test_argparse_backend_choices_include_nemotron`, `test_resolve_model` returns +`DEFAULT_NEMOTRON_MODEL` when `--model` unset, and +`test_make_backend_and_resolve_model_nemotron_do_not_import_mlx_audio` +(subprocess, blocks `mlx_audio`, calls both `_make_backend("nemotron", ...)` +and `_resolve_model("nemotron", None)`) — mirror `test_stt_server.py:921,929,973`. +Add the `BACKEND=nemotron` renderer allowlist test and the real-installer +`PIPECAT_STT_BACKEND=nemotron` default-model regression test in this same phase. +**Validation:** `--backend nemotron` constructs the backend; `_resolve_model` +returns `DEFAULT_NEMOTRON_MODEL` when `--model` unset; lazy-import (lean base) +preserved and **proven** by the no-import subprocess test; `_BACKEND_RE` accepts +`nemotron`; `install_stt_agent.sh` installs `DEFAULT_NEMOTRON_MODEL` for +`BACKEND=nemotron` (not Whisper); `echo`/`mlx`/`parakeet` unaffected. + +### Phase 3: Packaging + docs + +**Impl files:** `pyproject.toml`, `README.md`, `CHANGELOG.md` +**Test files:** packaging/docs grep checks only; backend/CLI/installer tests +already landed in Phases 1-2 +**Test command:** `uv run python -m pytest -q` +**Validation:** version `0.3.0`; Phase-0 dependency group remains locked in +`uv.lock`; **`uv build && unzip -p dist/*.whl '*/METADATA' | grep -i mlx-audio` +returns empty** (no direct-URL leak into `Requires-Dist`); README/CHANGELOG/socket +table updates land with grep-verified enumeration completeness; CHANGELOG and PR +description state Option 1, why no published `nemotron` extra exists yet, and +the PyPI-clean verification result; full suite + `ruff format`/`ruff check` +clean. + +## Out of scope + +- Activating cache-aware **streaming** / partial `delta`s (separate plan). +- Changing the V1 commit-oriented wire protocol or VAD/turn handling. +- Removing or renaming the `KODA_STT_*` env-var aliases. +- Cutting the upstream `mlx-audio` PyPI release (external dependency). + +## Known follow-ups (tracked, not done here) + +- **Extract the shared Metal-decode machinery.** With `nemotron`, the + in-flight-counter + 3.0 s `close()` drain + condition block becomes the **third** + near-identical copy (`parakeet.py:210-270`, `mlx_whisper.py:249-294`), and the + `0o700` temp-dir + WAV-write + `os.unlink` block the **second**. Extraction now + would be premature (the plan deliberately mirrors parakeet for review-diffability, + and only `_thread_util` is shared today), but once the three copies land and are + confirmed byte-identical, a single `_MetalDecodeBackend`-style consolidation pass + is the right cleanup — a tracked decision, not silent 3× copy-paste drift. +- **Promote the `nemotron` dev group to a published extra** once `mlx-audio` + releases a version containing PR #774 (clean version pin, see Dependency reality). + + +## Progress + +- [x] Phase 0: Dependency pin + real mlx-audio API verification +- [x] Phase 1: NemotronBackend + tests (stubbed mlx_audio) +- [x] Phase 2: CLI wiring + choice-enumeration tests +- [x] Phase 3: Packaging + docs + +## Findings + +### Phase 0 — integration probe (settled 2026-06-05, real post-#774 package) + +External facts re-verified: + +- **PR #774 MERGED**, merge commit `14add666b5313cadff94a231ee11979f6ac1adf7` + (merged 2026-06-05T17:11Z). Pinned in the `[dependency-groups]` `nemotron` + group. +- **PyPI `mlx-audio` latest is 0.4.3** (uploaded 2026-04-28) — predates #774, so + no released version carries Nemotron STT. Git-SHA pin (Option 1) is required; + no PyPI release to switch to. + +API verified against the **installed** package source (authoritative) and a real +load + synthetic decode on this machine: + +- Entry point `from mlx_audio.stt import load` works; `load(model_id)` returns + `mlx_audio.stt.models.nemotron_asr.nemotron_asr.Model`. +- `Model.generate(audio, *, language=None, att_context_size=None, dtype=float32, + verbose=False, **kwargs) -> AlignedResult`. The kwarg is **`language`** (NOT + `target_lang`). `audio` accepts a **file path** (`str`/`Path`); `load_audio` + runs internally — same shape as parakeet, unlike `mlx_whisper`'s array input. +- Return is `AlignedResult` (the *same* dataclass `parakeet-mlx` returns, from + `mlx_audio.stt.models.parakeet.alignment`); `.text` is present. The defensive + `getattr(result, "text", "") or ""` read carries over unchanged. +- **Language vocabulary**: `prompt_dictionary` has 121 keys including + `"auto"` (→101), `"en"`/`"en-US"` (→0), `"es-ES"` (→2), … + `default_language = "auto"`. `_resolve_prompt_index` falls back to + `default_language` then index 0 for unknown values — a wrong/unsupported + language string degrades gracefully, never raises. +- **No `strip_lang_tags` flag** exists on `generate`/`decode` — that integration + concern is closed (nothing to wire). +- **`DEFAULT_NEMOTRON_LANGUAGE = "auto"`** — decided. `"auto"` is a verified + accepted prompt key AND the model's own `default_language`; it is the headline + 40-locale LID mode advertised for this multilingual local server. The backend + forwards a client-supplied `language` and falls back to this constant when the + client sends `None`. One-line-swappable to `"en-US"` (also verified accepted) + if LID proves unreliable in practice. +- **End-to-end smoke**: `load(...).generate(, language="auto")` + returned `AlignedResult` with `.text == ""` for a 1 s 16 kHz sine tone — + exercising the empty-decode (`completed`-only) path. Full load + decode runs + on this machine; no partial-completion caveat needed. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6f25d8f..566b023 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pipecat-local-stt-server" -version = "0.2.0" +version = "0.3.0" description = "Standalone local WebSocket transcription (STT) server, client, and pluggable ASR backends for the Pipecat ecosystem" readme = "README.md" requires-python = ">=3.12" @@ -41,6 +41,25 @@ dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.24.0", "ruff>=0.8.0", + # Test infrastructure: backend tests synthesise PCM16LE audio with numpy. + # Lives in the always-synced dev group (not per-backend groups/extras) so + # the suite runs without an ASR runtime extra installed. + "numpy>=1.26", +] +# Nemotron 3.5 ASR backend runtime, git-pinned to the PR #774 merge commit +# (Blaizzy/mlx-audio#774, merged 2026-06-05) because no PyPI mlx-audio release +# yet contains Nemotron STT support — the latest, 0.4.3, was cut 2026-04-28, +# before #774 landed. This lives in a [dependency-groups] dev group rather than +# [project.optional-dependencies] on purpose: PEP 735 groups are NOT emitted +# into wheel/sdist Requires-Dist, whereas a direct-URL (PEP 508 ``@ ``) +# dependency in a published extra is rejected by PyPI. Keeping it here lets +# `uv sync --group nemotron` install the backend locally while 0.3.0 stays +# PyPI-installable. Promote to a clean ``nemotron`` extra with a version pin once +# mlx-audio publishes a release containing #774 (tracked follow-up). +nemotron = [ + # numpy is pulled transitively by mlx-audio at runtime; it is declared in + # the dev group for the test suite, so it is intentionally not repeated here. + "mlx-audio @ git+https://github.com/Blaizzy/mlx-audio.git@14add666b5313cadff94a231ee11979f6ac1adf7", ] [project.urls] diff --git a/scripts/install_stt_agent.sh b/scripts/install_stt_agent.sh index b259c71..d8c5053 100755 --- a/scripts/install_stt_agent.sh +++ b/scripts/install_stt_agent.sh @@ -12,9 +12,10 @@ # (default: pipecat.stt-server) # PIPECAT_STT_SOCKET (KODA_STT_SOCKET) path to the UDS socket # (default: $HOME/Library/Caches/pipecat-stt/stt.sock) -# PIPECAT_STT_BACKEND (KODA_STT_BACKEND) backend name: echo|mlx|parakeet (default: mlx) +# PIPECAT_STT_BACKEND (KODA_STT_BACKEND) backend name: echo|mlx|parakeet|nemotron (default: mlx) # PIPECAT_STT_MODEL (KODA_STT_MODEL) model id (default: backend-aware — Whisper repo for -# mlx/echo, mlx-community/parakeet-tdt-0.6b-v3 for parakeet) +# mlx/echo, mlx-community/parakeet-tdt-0.6b-v3 for parakeet, +# mlx-community/nemotron-3.5-asr-streaming-0.6b for nemotron) # # Two-agent install recipe (run Whisper and Parakeet ASR side by side): # @@ -57,10 +58,16 @@ SOCKET_PATH="${PIPECAT_STT_SOCKET:-${KODA_STT_SOCKET:-$HOME/Library/Caches/pipec BACKEND="${PIPECAT_STT_BACKEND:-${KODA_STT_BACKEND:-mlx}}" # Backend-aware MODEL default. render_stt_plist.py validates MODEL and # exits when unset, so supply a sensible default per backend here: the -# Whisper repo for mlx/echo, the Parakeet TDT model for parakeet. Must -# agree with DEFAULT_PARAKEET_MODEL in stt_server/backends/parakeet.py. +# Whisper repo for mlx/echo, the Parakeet TDT model for parakeet, the +# Nemotron 3.5 ASR model for nemotron. The parakeet default must agree with +# DEFAULT_PARAKEET_MODEL in stt_server/backends/parakeet.py; the nemotron +# default must agree with DEFAULT_NEMOTRON_MODEL in +# stt_server/backends/nemotron.py. Without the nemotron arm, BACKEND=nemotron +# would fall to the else and silently install the Whisper repo id. if [[ "$BACKEND" == "parakeet" ]]; then DEFAULT_MODEL="mlx-community/parakeet-tdt-0.6b-v3" +elif [[ "$BACKEND" == "nemotron" ]]; then + DEFAULT_MODEL="mlx-community/nemotron-3.5-asr-streaming-0.6b" else DEFAULT_MODEL="mlx-community/whisper-large-v3-turbo" fi diff --git a/scripts/mlx_teardown_spike.sh b/scripts/mlx_teardown_spike.sh index ff1cb94..9976e65 100755 --- a/scripts/mlx_teardown_spike.sh +++ b/scripts/mlx_teardown_spike.sh @@ -7,7 +7,7 @@ # # Prereqs: # - Apple Silicon -# - ``uv sync --extra stt-server-mlx`` has run +# - ``uv sync --extra mlx`` has run # - ``scripts/install_stt_agent.sh install`` has been run at least once # # Usage: diff --git a/scripts/render_stt_plist.py b/scripts/render_stt_plist.py index dc76ace..50d3a60 100644 --- a/scripts/render_stt_plist.py +++ b/scripts/render_stt_plist.py @@ -41,7 +41,7 @@ _ABSPATH_RE = re.compile(r"^/[A-Za-z0-9._/+\- @]+$") _MODEL_RE = re.compile(r"^[A-Za-z0-9._/\-]+$") -_BACKEND_RE = re.compile(r"^(echo|mlx|parakeet)$") +_BACKEND_RE = re.compile(r"^(echo|mlx|parakeet|nemotron)$") _LABEL_RE = re.compile(r"^[A-Za-z0-9._\-]+$") @@ -99,7 +99,9 @@ def main() -> None: socket_path = _require( "SOCKET_PATH", os.environ.get("SOCKET_PATH"), _ABSPATH_RE, "absolute path" ) - backend = _require("BACKEND", os.environ.get("BACKEND"), _BACKEND_RE, "echo|mlx|parakeet") + backend = _require( + "BACKEND", os.environ.get("BACKEND"), _BACKEND_RE, "echo|mlx|parakeet|nemotron" + ) model = _require( "MODEL", os.environ.get("MODEL"), diff --git a/stt_server/__init__.py b/stt_server/__init__.py index fa86318..14ce431 100644 --- a/stt_server/__init__.py +++ b/stt_server/__init__.py @@ -2,8 +2,7 @@ Repo-neutral package intended for later extraction into its own OSS repo. -Import layout reflects the planned ``stt-server-client`` vs -``stt-server-mlx`` extras split once extracted: +Import layout reflects the ``client`` vs ``mlx`` extras split: - protocol / client / backend interfaces are re-exported here, so a client-only install (no MLX, no asyncio server runtime) can diff --git a/stt_server/__main__.py b/stt_server/__main__.py index 64c83b5..c4d0fd6 100644 --- a/stt_server/__main__.py +++ b/stt_server/__main__.py @@ -47,7 +47,7 @@ def _make_backend(name: str, model: str): return MLXWhisperBackend(model=model) if name == "parakeet": - # Lazy import so a base install without the ``stt-server-parakeet`` + # Lazy import so a base install without the ``parakeet`` # extra still constructs ``echo``/``mlx`` backends. ``parakeet.py`` # imports ``parakeet_mlx`` only inside ``start()`` / ``_get_model``, # never at module load, so this import does NOT transitively pull @@ -56,6 +56,16 @@ def _make_backend(name: str, model: str): from .backends.parakeet import ParakeetBackend return ParakeetBackend(model=model) + if name == "nemotron": + # Lazy import so a base install without the ``nemotron`` extra still + # constructs ``echo``/``mlx`` backends. ``nemotron.py`` imports + # ``mlx_audio`` only inside ``start()`` / ``_get_model``, never at + # module load, so this import does NOT transitively pull ``mlx_audio`` + # — the missing-extra failure surfaces fast in ``start()``, not here at + # construction. + from .backends.nemotron import NemotronBackend + + return NemotronBackend(model=model) raise SystemExit(f"unknown backend: {name}") @@ -67,7 +77,8 @@ def _resolve_model(backend: str, model: str | None) -> str: mismatched repo id fails fast in ``start()``/decode, and classifying a repo id as "whisper" vs "parakeet" would need a brittle string heuristic). When ``--model`` is unset the default is backend-aware: ``parakeet`` uses - ``DEFAULT_PARAKEET_MODEL`` rather than the Whisper repo. + ``DEFAULT_PARAKEET_MODEL`` and ``nemotron`` uses ``DEFAULT_NEMOTRON_MODEL`` + rather than the Whisper repo. """ if model is not None: return model @@ -75,6 +86,10 @@ def _resolve_model(backend: str, model: str | None) -> str: from .backends.parakeet import DEFAULT_PARAKEET_MODEL return DEFAULT_PARAKEET_MODEL + if backend == "nemotron": + from .backends.nemotron import DEFAULT_NEMOTRON_MODEL + + return DEFAULT_NEMOTRON_MODEL return _DEFAULT_MLX_MODEL @@ -347,10 +362,13 @@ def main() -> None: p_serve = subparsers.add_parser("serve", help="run the server (default)") _add_endpoint_flags(p_serve) - p_serve.add_argument("--backend", choices=("echo", "mlx", "parakeet"), default="echo") + p_serve.add_argument( + "--backend", choices=("echo", "mlx", "parakeet", "nemotron"), default="echo" + ) # Default is None so ``_resolve_model`` can apply a backend-aware fallback - # (Whisper repo for ``mlx``, ``DEFAULT_PARAKEET_MODEL`` for ``parakeet``). - # An explicit value always wins and is passed through verbatim. + # (Whisper repo for ``mlx``, ``DEFAULT_PARAKEET_MODEL`` for ``parakeet``, + # ``DEFAULT_NEMOTRON_MODEL`` for ``nemotron``). An explicit value always + # wins and is passed through verbatim. p_serve.add_argument("--model", default=None) p_serve.add_argument("--log-level", default="INFO") diff --git a/stt_server/backends/nemotron.py b/stt_server/backends/nemotron.py new file mode 100644 index 0000000..576bbcf --- /dev/null +++ b/stt_server/backends/nemotron.py @@ -0,0 +1,282 @@ +"""Nemotron 3.5 ASR backend for Apple Silicon via ``mlx-audio``. + +V1 is commit-oriented: we accumulate PCM16LE audio until ``end()`` is called, +then run a single offline ``generate()`` decode and emit one ``delta`` plus one +``completed`` event. True streaming partials are deferred — ``mlx-audio`` +exposes a ``stream_generate()`` entrypoint, but it is intentionally unused +until a streaming wire protocol lands; Nemotron runs after smart-turn, so it +always sees a complete utterance and offline ``generate()`` is sufficient. + +Mirrors ``stt_server/backends/parakeet.py`` in structure: lazy import of the +optional ``mlx-audio`` package, a backend-scoped asyncio + threading decode +lock pair, an in-flight drain in ``close()`` for SIGTERM-mid-decode crash +isolation, and the same empty-decode contract (``delta`` only on non-empty +text, ``completed`` always). + +Three-way ``language`` contract across backends: + * ``parakeet`` accepts-and-ignores ``language`` (``parakeet.py:62-68``) — + its TDT models are language-pinned by model id and ``transcribe`` exposes + no per-call language kwarg; + * ``mlx_whisper`` forwards ``language`` to its decoder + (``mlx_whisper.py:141``); + * ``nemotron`` (this module) forwards ``language`` to ``generate()`` and, + when the client sends ``None``, falls back to ``DEFAULT_NEMOTRON_LANGUAGE`` + (``"auto"`` — the model's own LID-over-40-locales default). +There is deliberately no shared abstraction/registry — the per-call kwarg plus +the named ``DEFAULT_NEMOTRON_LANGUAGE`` constant is the minimal call. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import os +import shutil +import tempfile +import threading +import wave +from typing import AsyncGenerator + +from ..backend import TranscriptEvent +from ..protocol import ( + AUDIO_CHANNELS, + AUDIO_SAMPLE_RATE_HZ, + AUDIO_SAMPLE_WIDTH_BYTES, +) +from ._thread_util import run_in_daemon_thread + +logger = logging.getLogger("stt_server.backends.nemotron") + +# Default model for ``--backend nemotron``. Exported so Phase 2's +# backend-aware ``--model`` default imports it rather than hardcoding a second +# copy (single source of truth, mirrors ``DEFAULT_PARAKEET_MODEL``). +DEFAULT_NEMOTRON_MODEL = "mlx-community/nemotron-3.5-asr-streaming-0.6b" + +# Default ``language`` forwarded to ``generate()`` when the client supplies no +# language. ``"auto"`` is a verified accepted prompt key AND the model's own +# default (language identification over 40+ locales). Named so the policy is a +# one-line swap (single source of truth, mirrors ``DEFAULT_NEMOTRON_MODEL``). +DEFAULT_NEMOTRON_LANGUAGE = "auto" + + +class _NemotronStream: + def __init__( + self, + language: str | None, + decode_lock: asyncio.Lock, + thread_lock: threading.Lock, + backend: "NemotronBackend", + ) -> None: + # ``language`` IS forwarded to the decoder (unlike Parakeet, which + # accepts-and-ignores it). Nemotron 3.5 ASR does per-call language + # selection / identification, so the client-supplied value is honoured. + # ``None`` means "client did not specify" -> at decode time we fall back + # to ``DEFAULT_NEMOTRON_LANGUAGE`` ("auto") rather than passing ``None``, + # keeping the model's LID default explicit at the call site. + self._language = language + self._buf = bytearray() + self._ended = False + self._cancelled = False + self._result: str | None = None + self._decode_lock = decode_lock + self._thread_lock = thread_lock + self._backend = backend + + async def feed(self, chunk: bytes) -> None: + if self._cancelled: + return + self._buf.extend(chunk) + + async def end(self) -> None: + if self._ended or self._cancelled: + return + self._ended = True + # Serialize decodes across all sessions: mlx-audio holds one cached + # model and MLX/Metal is not safe for concurrent calls against it. The + # asyncio lock orders decodes event-loop side; the threading lock held + # inside ``_decode_sync`` keeps a second decode thread blocked even + # when the awaiter here is cancelled. Same pattern as ``_ParakeetStream``. + async with self._decode_lock: + if self._cancelled: + return + # Mark in-flight BEFORE spawning the daemon thread so + # ``backend.close()`` observes it immediately; the daemon thread + # owns the decrement in a finally block. + self._backend._mark_inflight_start() + self._result = await run_in_daemon_thread( + self._decode_sync, thread_name="nemotron-decode" + ) + + async def cancel(self) -> None: + self._cancelled = True + self._ended = True + + def _decode_sync(self) -> str: + # Decrement the backend-scope in-flight counter in a finally so the + # ``close()`` drain wait is correct across every exit path (normal + # return, decode raising, cancellation). + try: + # First-decode model-load failure and per-utterance decode failure + # both raise from inside this function; the exception is marshalled + # back through the Future and propagates out of ``end()`` / + # ``events()``. The backend never invents a ``failed`` event kind — + # the server's ``except`` arm synthesises the wire + # ``transcript.failed`` + ``BACKEND_ERROR``. + pcm = bytes(self._buf) + if not pcm: + return "" + # mlx-audio's ``generate()`` takes a file *path* (it runs + # ``load_audio`` internally) — exactly like parakeet's + # ``transcribe(path)`` and unlike ``mlx_whisper.transcribe`` it does + # NOT accept a raw audio array. Materialise the buffered PCM16LE + # audio as a temp WAV and hand generate() the path. The wire + # protocol pins channels / sample width / rate upstream, so the WAV + # header is fully determined by the protocol constants. The WAV + # holds raw utterance audio (PII); it is written inside the + # backend's private 0o700 temp dir, never the world-listable system + # temp dir — see ``NemotronBackend.__init__``. + fd, wav_path = tempfile.mkstemp(suffix=".wav", dir=self._backend._tmpdir) + os.close(fd) + try: + with wave.open(wav_path, "wb") as wav: + wav.setnchannels(AUDIO_CHANNELS) + wav.setsampwidth(AUDIO_SAMPLE_WIDTH_BYTES) + wav.setframerate(AUDIO_SAMPLE_RATE_HZ) + wav.writeframes(pcm) + # Effective language: forward the client value, falling back to + # the named "auto" default when the client did not specify one. + lang = self._language if self._language is not None else DEFAULT_NEMOTRON_LANGUAGE + # Hold the backend-scope threading lock for the entire decode + # so a second decode thread started after this stream's + # asyncio awaiter was cancelled still blocks until this one + # completes. ``_get_model()`` is acquired INSIDE the lock: a + # first-ever ``load`` does Metal work, and loading under the + # lock keeps it from racing another thread's ``generate()`` on + # the Metal device. + with self._thread_lock: + model = self._backend._get_model() # lazy load; may raise + # Nemotron's ``generate()`` decodes the full file offline. + # Unlike parakeet-mlx it takes no ``chunk_duration`` / + # ``overlap_duration`` kwargs — just the path and language. + result = model.generate(wav_path, language=lang) + finally: + with contextlib.suppress(OSError): + os.unlink(wav_path) + text = getattr(result, "text", "") or "" + return text.strip() + finally: + self._backend._mark_inflight_end() + + async def events(self) -> AsyncGenerator[TranscriptEvent, None]: + if self._cancelled or self._result is None: + return + text = self._result + # Match the empty-decode contract: ``delta`` only when the transcript + # text is non-empty (Nemotron on near-silence can produce empty text); + # ``completed`` always. + if text: + yield TranscriptEvent(kind="delta", text=text) + yield TranscriptEvent(kind="completed", text=text) + + +class NemotronBackend: + """ASR backend backed by NVIDIA Nemotron 3.5 ASR models via ``mlx-audio``.""" + + backend_name = "nemotron" + + def __init__(self, *, model: str = DEFAULT_NEMOTRON_MODEL) -> None: + self._model_id = model + # Public identity for the server.hello / server.status `backend` field. + self.model = model + # The loaded mlx-audio model. ``None`` until the first decode — ``load`` + # is eager and pulls a multi-hundred-MB checkpoint, so it is deferred + # out of ``start()`` (which only fails fast on a missing package). + # Guarded by ``_model_lock`` so two concurrent first decodes load it + # exactly once. + self._model = None + self._model_lock = threading.Lock() + self._decode_lock = asyncio.Lock() + # Backend-scope thread lock — shared across every stream so concurrent + # sessions truly serialize on the MLX/Metal side. mlx-audio is not + # verified concurrency-safe (one cached model, Metal command buffers), + # so the lock pair is kept exactly as ``ParakeetBackend`` does. + self._thread_lock = threading.Lock() + # Backend-scope in-flight counter for the ``close()`` drain. The + # SIGTERM-mid-decode Metal command-buffer assertion class is + # assumed-by-analogy here: it was observed with MLX Whisper / parakeet + # and is plausible for mlx-audio (a different package, FastConformer-RNNT + # rather than TDT), not verified identical. The in-flight drain is a + # harmless no-op if the crash class does not in fact reach mlx-audio. + self._inflight_count = 0 + self._inflight_cond = threading.Condition() + # Private 0o700 temp dir for per-utterance decode WAVs. ``mkdtemp`` + # makes it owner-only-traversable, so raw utterance audio (PII) is + # never written to the world-listable system temp dir and an orphaned + # WAV (SIGKILL between mkstemp and unlink) stays unreadable by other + # local users. Removed in ``close()``. + self._tmpdir = tempfile.mkdtemp(prefix="pipecat-stt-nemotron-") + + def _mark_inflight_start(self) -> None: + with self._inflight_cond: + self._inflight_count += 1 + + def _mark_inflight_end(self) -> None: + with self._inflight_cond: + self._inflight_count -= 1 + if self._inflight_count == 0: + self._inflight_cond.notify_all() + + def _wait_inflight_drained(self, timeout_s: float) -> bool: + with self._inflight_cond: + return self._inflight_cond.wait_for( + lambda: self._inflight_count == 0, + timeout=timeout_s, + ) + + def _get_model(self): + """Lazily load the Nemotron model on first decode. + + Called from the decode daemon thread. ``load`` is eager and downloads a + multi-hundred-MB checkpoint; deferring it here keeps ``start()`` cheap. + A model-load failure raises here and propagates out of ``events()`` / + ``end()`` exactly like a per-utterance decode failure — the server + converts either into the wire ``transcript.failed``. + """ + with self._model_lock: + if self._model is None: + from mlx_audio.stt import load # type: ignore + + self._model = load(self._model_id) + return self._model + + async def start(self) -> None: + # Eager import; fail fast before the socket binds if the ``mlx-audio`` + # package is not installed. The model itself is NOT loaded here — see + # ``_get_model``. + from mlx_audio.stt import load # type: ignore # noqa: F401 + + async def open_stream(self, *, language: str | None = None) -> "_NemotronStream": + return _NemotronStream(language, self._decode_lock, self._thread_lock, self) + + async def close(self) -> None: + # Give any in-flight Nemotron decode a bounded window to finish flushing + # Metal work before the process exits. The SIGTERM-mid-decode Metal + # command-buffer assertion class is assumed-by-analogy for mlx-audio + # (observed with MLX Whisper / parakeet; plausible but not verified for + # this package) — the drain is a harmless no-op if it never reaches + # mlx-audio. Same bounded drain as ``ParakeetBackend.close()``. + timeout_s = 3.0 + drained = await asyncio.get_running_loop().run_in_executor( + None, lambda: self._wait_inflight_drained(timeout_s) + ) + if not drained: + logger.warning( + "nemotron: in-flight decode did not finish within %.1fs; " + "Metal assertion possible at process exit", + timeout_s, + ) + # Remove the private decode-WAV temp dir. ``ignore_errors`` keeps + # shutdown best-effort — a leftover dir is harmless (0o700, owner-only) + # and a fresh one is created on the next process start. + shutil.rmtree(self._tmpdir, ignore_errors=True) diff --git a/stt_server/backends/parakeet.py b/stt_server/backends/parakeet.py index 37d4084..99a92c4 100644 --- a/stt_server/backends/parakeet.py +++ b/stt_server/backends/parakeet.py @@ -241,7 +241,7 @@ def _get_model(self): return self._model async def start(self) -> None: - # Eager import; fail fast if the ``stt-server-parakeet`` extra is not + # Eager import; fail fast if the ``parakeet`` extra is not # installed. The model itself is NOT loaded here — see ``_get_model``. import parakeet_mlx # type: ignore # noqa: F401 diff --git a/stt_server/client.py b/stt_server/client.py index 5b50bd8..fda1970 100644 --- a/stt_server/client.py +++ b/stt_server/client.py @@ -26,9 +26,9 @@ logger = logging.getLogger("stt_server.client") -# Public surface of this module. Declared so the extraction-time -# `stt-server-client` extra has a machine-readable signal for what -# callers outside the package may rely on — consistent with the +# Public surface of this module. Declared so the `client` extra has a +# machine-readable signal for what callers outside the package may rely +# on — consistent with the # `__all__` in ``stt_server/__init__.py``. __all__ = [ "TranscriptionClient", diff --git a/tests/test_install_migration.py b/tests/test_install_migration.py index 69a0249..d62ed0f 100644 --- a/tests/test_install_migration.py +++ b/tests/test_install_migration.py @@ -36,6 +36,13 @@ import pytest +# Import the Nemotron default from its single source of truth: the installer's +# DEFAULT_MODEL nemotron arm must agree with this constant, and pinning the +# expected value to the literal here would let a backend rename pass both the +# script and this regression against a stale id. nemotron.py imports mlx_audio +# only lazily, so this top-level import does not pull the optional package. +from stt_server.backends.nemotron import DEFAULT_NEMOTRON_MODEL as NEMOTRON_MODEL + REPO_ROOT = Path(__file__).resolve().parent.parent SCRIPT = REPO_ROOT / "scripts" / "install_stt_agent.sh" VENV_PYTHON = REPO_ROOT / ".venv" / "bin" / "python" @@ -406,6 +413,49 @@ def test_koda_socket_and_log_dir_env_override_new_defaults(tmp_path: Path): ) +# --------------------------------------------------------------------------- +# (6) Backend-aware DEFAULT_MODEL: nemotron installs the Nemotron repo id, +# never the Whisper default +# --------------------------------------------------------------------------- + +WHISPER_MODEL = "mlx-community/whisper-large-v3-turbo" + + +def test_nemotron_backend_install_uses_nemotron_default_model(tmp_path: Path): + """With ``PIPECAT_STT_BACKEND=nemotron`` and ``PIPECAT_STT_MODEL`` unset, + the installer's backend-aware ``DEFAULT_MODEL`` must resolve to the Nemotron + repo id — NOT the Whisper default that the ``else`` arm would yield. + + Proof: the install renders the plist into ``HOME`` via the real renderer; + the rendered ``ProgramArguments`` must carry the Nemotron model after the + ``--model`` flag. + """ + import plistlib + + stub_dir, _ = _make_stub_dir(tmp_path) + home = tmp_path / "home" + home.mkdir(parents=True, exist_ok=True) + + r = _run_install( + tmp_path, + stub_dir, + env_overrides={"PIPECAT_STT_BACKEND": "nemotron"}, + ) + assert r.returncode == 0, f"stdout={r.stdout!r} stderr={r.stderr!r}" + + rendered = home / "Library" / "LaunchAgents" / f"{NEW_LABEL}.plist" + assert rendered.is_file(), f"expected rendered plist at {rendered}" + args = plistlib.loads(rendered.read_bytes())["ProgramArguments"] + + assert "nemotron" in args, f"backend should be nemotron; args={args}" + assert args[args.index("--model") + 1] == NEMOTRON_MODEL, ( + f"nemotron install must use the Nemotron default model, not Whisper; args={args}" + ) + assert WHISPER_MODEL not in args, ( + f"nemotron install must NOT fall back to the Whisper default; args={args}" + ) + + def test_shutil_which_bash_available(): """Sanity guard: the test harness needs a real ``bash`` to invoke the script — surface a clear failure rather than an opaque subprocess error.""" diff --git a/tests/test_nemotron_backend.py b/tests/test_nemotron_backend.py new file mode 100644 index 0000000..61af41d --- /dev/null +++ b/tests/test_nemotron_backend.py @@ -0,0 +1,532 @@ +"""Tests for the Nemotron 3.5 ASR STT backend. + +``mlx_audio`` is fully stubbed via ``sys.modules`` injection so CI never +downloads a model. These tests pin the V1 wire contract the backend must +satisfy: + + * ``NemotronBackend`` / ``_NemotronStream`` satisfy the structural + ``TranscriptionBackend`` / ``BackendStream`` protocols (``backend.py``); + * a non-empty stubbed decode yields exactly one ``delta`` + one + ``completed`` ``TranscriptEvent``; + * an empty-text stubbed decode yields ``completed`` only, no ``delta``; + * a whitespace-only decode is treated as empty (exercises ``.strip()``); + * a stubbed decode that raises mid-decode propagates the exception out of + ``events()`` / ``end()`` — no swallowed empty ``completed``, no + ``failed`` event kind; + * a first-decode model-load failure raises the same way from its distinct + call site; + * ``cancel()`` before ``end()`` yields no events; ``cancel()`` mid-decode + is bounded and crash-free; + * a 60 s+ stubbed utterance is not silently truncated; + * two overlapping decodes serialise on the backend-scoped lock; + * the client-supplied ``language`` is FORWARDED to ``generate()`` (the one + material difference from Parakeet) and a ``None`` language falls back to + ``DEFAULT_NEMOTRON_LANGUAGE``; + * ``DEFAULT_NEMOTRON_MODEL`` is a non-empty string constant; + * temp-dir / PII + shutdown invariants hold (0o700, unlink, close-drain). + +Audio is a programmatic synthetic PCM16LE buffer — no binary fixtures are +committed (PII / repo-bloat policy). +""" + +from __future__ import annotations + +import asyncio +import os +import stat +import sys +import types +import wave +from typing import Any + +import numpy as np +import pytest + +# 16 kHz PCM16LE is the wire sample rate enforced by the protocol. +_SAMPLE_RATE_HZ = 16_000 + + +# --------------------------------------------------------------------------- +# mlx_audio stub — installed before the backend module is imported so the +# backend's lazy ``from mlx_audio.stt import load`` resolves to this fake. +# --------------------------------------------------------------------------- + + +class _FakeAlignedResult: + """Mimics the object ``mlx_audio`` model.generate() returns. + + The real object (``AlignedResult``) exposes a ``.text`` attribute. The + backend only needs the final text, so a ``.text`` carrier is the minimal + contract. + """ + + def __init__(self, text: str) -> None: + self.text = text + + +class _FakeNemotronModel: + """Stub for the model object returned by ``mlx_audio.stt.load``.""" + + def __init__(self) -> None: + self.return_text = "hello from nemotron" + self.raise_on_generate: BaseException | None = None + self.generate_calls = 0 + self.last_path: Any = None + self.last_language: Any = "" + self.last_frame_count: int | None = None + # When set, the model blocks inside generate() until released — used to + # exercise cancel-mid-decode and decode serialisation. + self._gate: "threading_Event | None" = None + + def generate( + self, path: Any, *args: Any, language: Any = None, **kwargs: Any + ) -> _FakeAlignedResult: + # The real ``mlx_audio`` ``generate()`` takes a file *path*. The backend + # writes the buffered PCM to a temp WAV and passes the path; capture the + # decoded frame count by reading that WAV so tests can assert no silent + # truncation. Read before the gate — the backend unlinks the temp file + # only after ``generate()`` returns. + self.generate_calls += 1 + self.last_path = path + self.last_language = language + try: + with wave.open(str(path), "rb") as w: + self.last_frame_count = w.getnframes() + except (OSError, wave.Error): + self.last_frame_count = None + if self._gate is not None: + self._gate.wait() + if self.raise_on_generate is not None: + raise self.raise_on_generate + return _FakeAlignedResult(self.return_text) + + +# Imported lazily inside the stub to keep the module import cheap. +import threading as _threading # noqa: E402 + +threading_Event = _threading.Event + + +class _FakeMLXAudioSTTModule(types.ModuleType): + """Drop-in replacement for the ``mlx_audio.stt`` submodule.""" + + def __init__(self) -> None: + super().__init__("mlx_audio.stt") + self.model = _FakeNemotronModel() + self.load_calls: list[str] = [] + self.raise_on_load: BaseException | None = None + + def load(self, model_id: str, *args: Any, **kwargs: Any) -> _FakeNemotronModel: + self.load_calls.append(model_id) + if self.raise_on_load is not None: + raise self.raise_on_load + return self.model + + +@pytest.fixture +def fake_nemotron(monkeypatch): + """Install the fake ``mlx_audio``/``mlx_audio.stt`` modules and (re)import + the backend. + + The backend module is dropped from ``sys.modules`` so it re-imports against + the fake — its lazy ``from mlx_audio.stt import load`` then resolves here. + """ + stt_mod = _FakeMLXAudioSTTModule() + pkg = types.ModuleType("mlx_audio") + pkg.stt = stt_mod # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "mlx_audio", pkg) + monkeypatch.setitem(sys.modules, "mlx_audio.stt", stt_mod) + monkeypatch.delitem(sys.modules, "stt_server.backends.nemotron", raising=False) + return stt_mod + + +@pytest.fixture +def nemotron_mod(fake_nemotron): + """The freshly imported ``stt_server.backends.nemotron`` module.""" + import stt_server.backends.nemotron as mod + + return mod + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _pcm(num_samples: int, *, amplitude: int = 800) -> bytes: + """Return ``num_samples`` of non-silent PCM16LE audio.""" + sig = (np.ones(num_samples, dtype=np.int16) * amplitude).tobytes() + return sig + + +def _pcm_seconds(seconds: float) -> bytes: + return _pcm(int(seconds * _SAMPLE_RATE_HZ)) + + +async def _drive(backend, *, language: str | None = "en", audio: bytes | None = None): + """Open a stream, feed audio, end it, and collect emitted events.""" + if audio is None: + audio = _pcm(_SAMPLE_RATE_HZ) # 1 s + stream = await backend.open_stream(language=language) + await stream.feed(audio) + await stream.end() + events = [ev async for ev in stream.events()] + return stream, events + + +# --------------------------------------------------------------------------- +# Structural protocol conformance +# --------------------------------------------------------------------------- + + +def test_backend_satisfies_transcription_backend_protocol(nemotron_mod): + from stt_server.backend import TranscriptionBackend + + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + assert isinstance(backend, TranscriptionBackend) + + +async def test_stream_satisfies_backend_stream_protocol(nemotron_mod): + from stt_server.backend import BackendStream + + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + stream = await backend.open_stream(language="en") + assert isinstance(stream, BackendStream) + await stream.cancel() + + +def test_default_nemotron_model_constant_is_nonempty_string(nemotron_mod): + val = nemotron_mod.DEFAULT_NEMOTRON_MODEL + assert isinstance(val, str) + assert val.strip(), "DEFAULT_NEMOTRON_MODEL must be a non-empty string" + + +def test_backend_exposes_identity(nemotron_mod): + """``backend_name`` / ``model`` feed the server.hello backend field.""" + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + assert backend.backend_name == "nemotron" + assert backend.model == "fake-nemotron" + + +def test_no_failed_event_kind_defined(nemotron_mod): + """The backend must not invent a ``failed`` TranscriptEvent kind.""" + with open(nemotron_mod.__file__, encoding="utf-8") as f: + src = f.read() + # A literal "failed" kind would be a protocol violation; decode failure + # is signalled by raising. + assert 'kind="failed"' not in src + assert "kind='failed'" not in src + + +# --------------------------------------------------------------------------- +# Lazy import discipline — mlx_audio must not load at module/__init__ time +# --------------------------------------------------------------------------- + + +def test_init_does_not_load_model(nemotron_mod, fake_nemotron): + """Constructing the backend must not call ``load``.""" + nemotron_mod.NemotronBackend(model="fake-nemotron") + assert fake_nemotron.load_calls == [] + + +async def test_start_does_not_load_model(nemotron_mod, fake_nemotron): + """``start()`` does an eager import to fail fast but defers model load + to the first decode (the model must not load in start()).""" + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + assert fake_nemotron.load_calls == [], "model must load lazily on first decode, not in start()" + + +async def test_model_loads_on_first_decode(nemotron_mod, fake_nemotron): + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + await _drive(backend) + assert fake_nemotron.load_calls == ["fake-nemotron"] + + +# --------------------------------------------------------------------------- +# TranscriptEvent sequence — non-empty and empty-text decode +# --------------------------------------------------------------------------- + + +async def test_nonempty_decode_yields_delta_then_completed(nemotron_mod, fake_nemotron): + fake_nemotron.model.return_text = "the quarterly numbers look solid" + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + _stream, events = await _drive(backend) + + assert [ev.kind for ev in events] == ["delta", "completed"] + assert events[0].text == "the quarterly numbers look solid" + assert events[1].text == "the quarterly numbers look solid" + + +async def test_empty_text_decode_yields_completed_only(nemotron_mod, fake_nemotron): + """Near-silence: Nemotron returns empty text -> completed only, no delta.""" + fake_nemotron.model.return_text = "" + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + _stream, events = await _drive(backend) + + assert [ev.kind for ev in events] == ["completed"] + assert events[0].text == "" + + +async def test_whitespace_only_decode_yields_completed_only(nemotron_mod, fake_nemotron): + """Whitespace-only decode is treated as empty (no meaningful delta).""" + fake_nemotron.model.return_text = " " + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + _stream, events = await _drive(backend) + assert "delta" not in [ev.kind for ev in events] + assert events[-1].kind == "completed" + + +# --------------------------------------------------------------------------- +# Decode-failure path — raise, do not emit +# --------------------------------------------------------------------------- + + +async def test_decode_failure_propagates_out_of_end_or_events(nemotron_mod, fake_nemotron): + """A stubbed decode raising mid-decode must propagate the exception.""" + fake_nemotron.model.raise_on_generate = RuntimeError("metal OOM mid-decode") + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + stream = await backend.open_stream(language="en") + await stream.feed(_pcm(_SAMPLE_RATE_HZ)) + + with pytest.raises(RuntimeError, match="metal OOM mid-decode"): + await stream.end() + # If end() did not raise, the decode is deferred to events(). + _ = [ev async for ev in stream.events()] + + +async def test_decode_failure_does_not_emit_silent_completed(nemotron_mod, fake_nemotron): + """No swallowed empty ``completed`` may slip through on decode failure.""" + fake_nemotron.model.raise_on_generate = RuntimeError("decode boom") + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + stream = await backend.open_stream(language="en") + await stream.feed(_pcm(_SAMPLE_RATE_HZ)) + + events: list[Any] = [] + with pytest.raises(RuntimeError): + try: + await stream.end() + finally: + async for ev in stream.events(): + events.append(ev) + assert events == [], "decode failure must raise, not emit a fake completed" + + +async def test_first_decode_model_load_failure_propagates(nemotron_mod, fake_nemotron): + """First-decode model-load failure raises from its distinct call site.""" + fake_nemotron.raise_on_load = RuntimeError("model download failed") + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() # eager import succeeds; model load is deferred + stream = await backend.open_stream(language="en") + await stream.feed(_pcm(_SAMPLE_RATE_HZ)) + + with pytest.raises(RuntimeError, match="model download failed"): + await stream.end() + _ = [ev async for ev in stream.events()] + + +# --------------------------------------------------------------------------- +# cancel() semantics +# --------------------------------------------------------------------------- + + +async def test_cancel_before_end_yields_no_events(nemotron_mod, fake_nemotron): + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + stream = await backend.open_stream(language="en") + await stream.feed(_pcm(_SAMPLE_RATE_HZ)) + await stream.cancel() + + events = [ev async for ev in stream.events()] + assert events == [] + # A cancelled stream must not have run a decode. + assert fake_nemotron.model.generate_calls == 0 + + +async def test_cancel_mid_decode_is_bounded_and_crash_free(nemotron_mod, fake_nemotron): + """cancel() while a decode is in flight must not crash and must return + promptly — the awaiting end() coroutine may unwind, but cancel() itself + is bounded.""" + gate = threading_Event() + fake_nemotron.model._gate = gate + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + stream = await backend.open_stream(language="en") + await stream.feed(_pcm(_SAMPLE_RATE_HZ)) + + end_task = asyncio.create_task(stream.end()) + # Let the decode reach the gate. + await asyncio.sleep(0.05) + + # cancel() must return promptly even though a decode thread is blocked. + await asyncio.wait_for(stream.cancel(), timeout=2.0) + + # Release the decode thread so it can exit cleanly. + gate.set() + # end() must settle (return or raise CancelledError) — never hang. + try: + await asyncio.wait_for(end_task, timeout=3.0) + except (asyncio.CancelledError, Exception): + pass + # A cancelled stream yields no events regardless of decode outcome. + events = [ev async for ev in stream.events()] + assert events == [] + + +# --------------------------------------------------------------------------- +# Long-utterance guard — a 60 s+ utterance must not be silently truncated +# --------------------------------------------------------------------------- + + +async def test_long_utterance_not_silently_truncated(nemotron_mod, fake_nemotron): + """A 60 s utterance must reach the decoder without dropped bytes.""" + seconds = 60.0 + audio = _pcm_seconds(seconds) + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + _stream, events = await _drive(backend, audio=audio) + + # The decode must have happened and produced the normal event shape. + assert events[-1].kind == "completed" + assert fake_nemotron.model.generate_calls >= 1 + + # No silent truncation: the WAV the model decoded spans the full 60 s. + frames = fake_nemotron.model.last_frame_count + assert frames is not None, "model did not receive a readable WAV path" + duration_s = frames / _SAMPLE_RATE_HZ + assert duration_s == pytest.approx(seconds, rel=0.02), ( + f"60s utterance silently truncated to {duration_s:.1f}s" + ) + + +# --------------------------------------------------------------------------- +# Decode serialisation — two overlapping decodes must not run concurrently +# --------------------------------------------------------------------------- + + +async def test_overlapping_decodes_serialise(nemotron_mod, fake_nemotron): + """Two streams from one backend must not decode concurrently. + + mlx-audio / Metal is not verified concurrency-safe, so the backend keeps a + backend-scoped decode lock. This test gates the first decode and proves the + second cannot enter ``generate()`` until the first releases. + """ + gate = threading_Event() + model = fake_nemotron.model + model._gate = gate + + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + + s1 = await backend.open_stream(language="en") + s2 = await backend.open_stream(language="en") + await s1.feed(_pcm(_SAMPLE_RATE_HZ)) + await s2.feed(_pcm(_SAMPLE_RATE_HZ)) + + t1 = asyncio.create_task(s1.end()) + await asyncio.sleep(0.05) # let decode 1 reach the gate + t2 = asyncio.create_task(s2.end()) + await asyncio.sleep(0.05) # give decode 2 a chance to (wrongly) start + + # With serialisation, only decode 1 has entered generate(). + assert model.generate_calls == 1, "second decode must wait for the lock" + + gate.set() # release decode 1; decode 2 may now proceed + await asyncio.wait_for(asyncio.gather(t1, t2), timeout=5.0) + assert model.generate_calls == 2 + + +# --------------------------------------------------------------------------- +# Language handling — the one material difference from Parakeet +# --------------------------------------------------------------------------- + + +async def test_client_language_is_forwarded_to_generate(nemotron_mod, fake_nemotron): + """(a) forwarding (deterministic): a client-supplied language reaches the + stub generate()'s ``language`` kwarg. Asserts forwarded-not-ignored + regardless of the default value.""" + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + await _drive(backend, language="es-ES") + assert fake_nemotron.model.last_language == "es-ES" + + +async def test_none_language_falls_back_to_default(nemotron_mod, fake_nemotron): + """(b) None-default (gated on the constant): with the client sending None, + generate() receives exactly ``DEFAULT_NEMOTRON_LANGUAGE``.""" + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + await _drive(backend, language=None) + assert fake_nemotron.model.last_language == nemotron_mod.DEFAULT_NEMOTRON_LANGUAGE + assert nemotron_mod.DEFAULT_NEMOTRON_LANGUAGE == "auto" + + +# --------------------------------------------------------------------------- +# PII / temp-dir + shutdown invariants +# --------------------------------------------------------------------------- + + +def test_temp_dir_is_owner_only(nemotron_mod): + """The decode-WAV temp dir must be 0o700 (owner-only) so PII audio is not + world-readable.""" + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + mode = stat.S_IMODE(os.stat(backend._tmpdir).st_mode) + assert mode == 0o700, f"temp dir mode is {oct(mode)}, expected 0o700" + # cleanup + import shutil + + shutil.rmtree(backend._tmpdir, ignore_errors=True) + + +async def test_temp_wav_is_unlinked_after_decode(nemotron_mod, fake_nemotron): + """The per-utterance temp WAV is written under the private dir and unlinked + after the decode returns — no PII left on disk.""" + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + await _drive(backend) + + # The path the model saw was inside the backend's private temp dir... + seen = fake_nemotron.model.last_path + assert seen is not None + assert str(seen).startswith(backend._tmpdir) + # ...and it no longer exists. + assert not os.path.exists(seen), "temp WAV must be unlinked after decode" + await backend.close() + + +async def test_close_removes_temp_dir(nemotron_mod, fake_nemotron): + """``close()`` removes the private temp dir.""" + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + tmpdir = backend._tmpdir + assert os.path.isdir(tmpdir) + await backend.close() + assert not os.path.exists(tmpdir) + + +async def test_close_waits_while_decode_in_flight(nemotron_mod, fake_nemotron): + """``close()`` bounds its wait while a decode is in flight (drain).""" + gate = threading_Event() + fake_nemotron.model._gate = gate + backend = nemotron_mod.NemotronBackend(model="fake-nemotron") + await backend.start() + stream = await backend.open_stream(language="en") + await stream.feed(_pcm(_SAMPLE_RATE_HZ)) + + end_task = asyncio.create_task(stream.end()) + await asyncio.sleep(0.05) # let decode reach the gate + + # close() must observe the in-flight decode and wait (bounded). Release the + # gate from a timer so the drain completes within the 3.0s bound. + loop = asyncio.get_running_loop() + loop.call_later(0.1, gate.set) + await asyncio.wait_for(backend.close(), timeout=3.5) + + await asyncio.wait_for(end_task, timeout=3.0) diff --git a/tests/test_render_stt_plist.py b/tests/test_render_stt_plist.py index baaaadd..363f100 100644 --- a/tests/test_render_stt_plist.py +++ b/tests/test_render_stt_plist.py @@ -30,6 +30,12 @@ import pytest +# Import the Nemotron default model from its single source of truth rather than +# re-hardcoding the literal: a model-id rename in the backend must fail this +# renderer regression instead of passing against a stale copy. nemotron.py +# imports mlx_audio only lazily, so this does not pull the optional package. +from stt_server.backends.nemotron import DEFAULT_NEMOTRON_MODEL as _DEFAULT_NEMOTRON_MODEL + REPO_ROOT = Path(__file__).resolve().parent.parent SCRIPT = REPO_ROOT / "scripts" / "render_stt_plist.py" INSTALL_SCRIPT = REPO_ROOT / "scripts" / "install_stt_agent.sh" @@ -252,14 +258,17 @@ def test_explicit_legacy_label_renders_legacy_label_and_log_paths(tmp_path: Path # --------------------------------------------------------------------------- -@pytest.mark.parametrize("backend", ["echo", "mlx", "parakeet"]) +@pytest.mark.parametrize("backend", ["echo", "mlx", "parakeet", "nemotron"]) def test_backend_allowlist_accepts_supported_backends(tmp_path: Path, backend: str): - """``_BACKEND_RE`` must accept ``echo``, ``mlx``, and the new ``parakeet``.""" + """``_BACKEND_RE`` must accept ``echo``, ``mlx``, ``parakeet``, and the new + ``nemotron``.""" dst = tmp_path / f"{backend}.plist" - # parakeet needs a model id its regex accepts; supply one explicitly. + # parakeet/nemotron need a model id their regex accepts; supply one explicitly. overrides = {"BACKEND": backend} if backend == "parakeet": overrides["MODEL"] = _DEFAULT_PARAKEET_MODEL + elif backend == "nemotron": + overrides["MODEL"] = _DEFAULT_NEMOTRON_MODEL r = _run_render(overrides, dst) assert r.returncode == 0, f"backend={backend}: stdout={r.stdout!r} stderr={r.stderr!r}" plist = plistlib.loads(dst.read_bytes()) @@ -285,6 +294,7 @@ def test_backend_allowlist_rejects_bogus_backend(tmp_path: Path): [ ("mlx", "mlx-community/whisper-large-v3-turbo"), ("parakeet", _DEFAULT_PARAKEET_MODEL), + ("nemotron", _DEFAULT_NEMOTRON_MODEL), ], ) def test_backend_aware_model_default_passes_model_regex(tmp_path: Path, backend: str, model: str): diff --git a/tests/test_stt_server.py b/tests/test_stt_server.py index b8f5d78..5b64c49 100644 --- a/tests/test_stt_server.py +++ b/tests/test_stt_server.py @@ -900,6 +900,32 @@ def _from_pretrained(model_id, *a, **kw): # pragma: no cover - not driven here return fake +@pytest.fixture +def fake_mlx_audio(monkeypatch): + """Install a permissive fake ``mlx_audio`` so the real ``nemotron`` backend + module can be imported/constructed in CI without a model download. + + Mirrors ``fake_parakeet_mlx``: ``nemotron.py``'s own ``import mlx_audio`` + lives inside ``start()``/decode, so for pure construction tests the stub is + not strictly needed — but installing it keeps the tests robust if the import + discipline ever regresses. + """ + import types as _types + + fake = _types.ModuleType("mlx_audio") + fake_stt = _types.ModuleType("mlx_audio.stt") + + def _load(model_id, *a, **kw): # pragma: no cover - not driven here + raise AssertionError("real model load attempted in a wiring test") + + fake_stt.load = _load + fake.stt = fake_stt + monkeypatch.setitem(sys.modules, "mlx_audio", fake) + monkeypatch.setitem(sys.modules, "mlx_audio.stt", fake_stt) + monkeypatch.delitem(sys.modules, "stt_server.backends.nemotron", raising=False) + return fake + + # --- _make_backend / _resolve_model / argparse choices -------------------- @@ -974,7 +1000,7 @@ def test_make_backend_parakeet_arm_does_not_import_parakeet_mlx(): """Lean-base invariant: importing/constructing the ``parakeet`` arm of ``_make_backend`` must not transitively import ``parakeet_mlx``. - Run in a clean subprocess with the ``stt-server-parakeet`` extra assumed + Run in a clean subprocess with the ``parakeet`` extra assumed absent — ``parakeet_mlx`` is removed from ``sys.modules`` and import is blocked, so a transitive pull would raise. The base install must still construct the backend; the missing-extra failure belongs in ``start()``. @@ -1008,6 +1034,85 @@ def test_make_backend_parakeet_arm_does_not_import_parakeet_mlx(): assert "OK" in r.stdout +def test_make_backend_nemotron_constructs_nemotron_backend(fake_mlx_audio): + from stt_server.__main__ import _make_backend + from stt_server.backends.nemotron import NemotronBackend + + backend = _make_backend("nemotron", "fake-nemotron-model") + assert isinstance(backend, NemotronBackend) + + +def test_argparse_backend_choices_include_nemotron(): + """``--backend nemotron`` must be an accepted argparse choice.""" + r = _run_module("serve", "--help") + assert r.returncode == 0, r.stderr + # argparse renders the choice tuple in the --backend metavar/help. + assert "nemotron" in r.stdout + + +def test_argparse_rejects_unknown_backend_with_nemotron_present(): + """A backend outside the choice tuple must still exit non-zero now that + ``nemotron`` is an accepted choice — parity with the parakeet reject test.""" + r = _run_module("serve", "--backend", "bogus") + assert r.returncode != 0 + assert "nemotron" in r.stderr or "invalid choice" in r.stderr + + +def test_resolve_model_nemotron_unset_uses_default_nemotron_model(fake_mlx_audio): + from stt_server.__main__ import _resolve_model + from stt_server.backends.nemotron import DEFAULT_NEMOTRON_MODEL + + assert _resolve_model("nemotron", None) == DEFAULT_NEMOTRON_MODEL + + +def test_resolve_model_explicit_override_wins_for_nemotron(fake_mlx_audio): + from stt_server.__main__ import _resolve_model + + assert _resolve_model("nemotron", "my-org/custom-nemotron") == "my-org/custom-nemotron" + + +def test_make_backend_and_resolve_model_nemotron_do_not_import_mlx_audio(): + """Lean-base invariant: the ``nemotron`` arm of ``_make_backend`` AND + ``_resolve_model`` must not transitively import ``mlx_audio``. + + Run in a clean subprocess with the ``nemotron`` extra assumed absent — + ``mlx_audio`` is removed from ``sys.modules`` and import is blocked, so a + transitive pull would raise. The base install must still construct the + backend and resolve the default model; the missing-extra failure belongs in + ``start()``. + """ + code = ( + "import sys, builtins\n" + "sys.modules.pop('mlx_audio', None)\n" + "_real_import = builtins.__import__\n" + "def _blocked(name, *a, **k):\n" + " if name == 'mlx_audio' or name.startswith('mlx_audio.'):\n" + " raise AssertionError('mlx_audio imported at nemotron seam')\n" + " return _real_import(name, *a, **k)\n" + "builtins.__import__ = _blocked\n" + "from stt_server.__main__ import _make_backend, _resolve_model\n" + "from stt_server.backends.nemotron import DEFAULT_NEMOTRON_MODEL\n" + "m = _resolve_model('nemotron', None)\n" + "assert m == DEFAULT_NEMOTRON_MODEL, m\n" + "b = _make_backend('nemotron', m)\n" + "assert type(b).__name__ == 'NemotronBackend'\n" + "assert 'mlx_audio' not in sys.modules\n" + "print('OK')\n" + ) + env = dict(os.environ) + repo_root = str(Path(__file__).resolve().parent.parent) + env["PYTHONPATH"] = os.pathsep.join([repo_root, env.get("PYTHONPATH", "")]).rstrip(os.pathsep) + r = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=20, + env=env, + ) + assert r.returncode == 0, f"stdout={r.stdout!r} stderr={r.stderr!r}" + assert "OK" in r.stdout + + # --- Wire-parity: stubbed ParakeetBackend through the server fixture ------- # # These mirror ``_SlowBackend`` (above) — a minimal duck-typed backend driven diff --git a/uv.lock b/uv.lock index d680c5e..16f2bdc 100644 --- a/uv.lock +++ b/uv.lock @@ -634,6 +634,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "miniaudio" +version = "1.71" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/d5/e5439dc08561f73656bfeb3340fc64ab63163e101426593d8fb9a025ff1e/miniaudio-1.71.tar.gz", hash = "sha256:ff51e2887bb673e2e757752b586b3dc924d59aa5fbcae9bbc45f4a111bd3262b", size = 1116480, upload-time = "2026-04-29T21:20:38.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/d3/71124f5abbcdcae62e040f58d3dca3bd3d90fd01faa7bb276b112387b47a/miniaudio-1.71-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:62db602651bc20a2698f36a0d356d7217ed6f4f917550c7ffb3705c8e8be90cf", size = 377186, upload-time = "2026-04-29T21:20:16.713Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ac/30a324f758bed1b193e017ec25183cfb10a79e549656331f5d068a2d343a/miniaudio-1.71-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fc1a4f084cc1b4b25c567d22f54d1e46bfa505c17ed777c8b198e5c53d0f785", size = 351485, upload-time = "2026-04-29T21:20:17.761Z" }, + { url = "https://files.pythonhosted.org/packages/fa/62/ae884a9d3b2ebec9c2ed1db857593e74bff45b70c4ab17fccc11db31cbeb/miniaudio-1.71-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19be6f0a1e601c2237433e579734cfaf6469191b224c20c9e5f73c32ef9ee2b9", size = 643556, upload-time = "2026-04-29T21:20:18.87Z" }, + { url = "https://files.pythonhosted.org/packages/a5/39/84fc665e2ea8f9f1301b6370226e3a24f08d5ad5d97143b69b4ae7ac260a/miniaudio-1.71-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e6287f15caa808a88aad0700a182bec1ff6d98769717425adf9ebf41259d1936", size = 645176, upload-time = "2026-04-29T21:20:20.194Z" }, + { url = "https://files.pythonhosted.org/packages/81/b8/37d9f67d4511da29bdb82b6c73a3ef6f4ebf2fbd30f9524f1e4a84d6f033/miniaudio-1.71-cp312-cp312-win32.whl", hash = "sha256:ab100e5240b104b5326e4ec1be07b6ae461f7d3d4d7a694857fd2f0493d210f9", size = 235095, upload-time = "2026-04-29T21:20:21.653Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/c1a19e6800e725b6e2b4576407620a798d69e3528ebdea9aea84d69d7088/miniaudio-1.71-cp312-cp312-win_amd64.whl", hash = "sha256:f4a44b70b66628b0c307e40ae0ae857695978cae18462179b806d8edc807d416", size = 274252, upload-time = "2026-04-29T21:20:22.824Z" }, + { url = "https://files.pythonhosted.org/packages/3a/85/44545f767ec21142ffed5f9108406d11dc8a19aafed9bd57621a0892bb60/miniaudio-1.71-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:61b86f26d653040db32d9d15b05446321dd10e45beba25b44f841e26935213d5", size = 377184, upload-time = "2026-04-29T21:20:24.109Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d1/071a560000c8ce903dc919968ecce40fbe7a73213ac399051b887184f8a3/miniaudio-1.71-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d9dc15eff711bcfc62a9d05e0c78e4bc34821a455595e049629f2fea7491a523", size = 351488, upload-time = "2026-04-29T21:20:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/46/24/5873a569451cae5686fb656ebd78ffe0b5eebe48ca21ef61e227d237d20a/miniaudio-1.71-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12bc33e7e61072b4b541c14e10ef76119d5643e6bbb98e2dec0c0738889438fb", size = 643552, upload-time = "2026-04-29T21:20:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/90/9b/25785525e6b5ff9afd7f4c4279215dc09c3317ea4d837275b1ae17912b36/miniaudio-1.71-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70fa2ea5353e6919aca59b8c5768144af009d18c3bca251749d66fb497424563", size = 645171, upload-time = "2026-04-29T21:20:27.494Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/cbfd55fdc40256231f7b0c861e2bf79cc289bfbbe5e15869317944e6d673/miniaudio-1.71-cp313-cp313-win32.whl", hash = "sha256:1bf93aeede652926f27f430f0fd69ef0cf8a949c07b537d6a2f295602c747037", size = 235088, upload-time = "2026-04-29T21:20:29.031Z" }, + { url = "https://files.pythonhosted.org/packages/8d/8d/d5059c04b247b1079c0e48914a9ec20352910a3b9373060fb258dfd194ab/miniaudio-1.71-cp313-cp313-win_amd64.whl", hash = "sha256:4c849ccb1349f7b3553a77a66fe7e972315185f5c4c44a0bbda7ebcdd224db37", size = 274247, upload-time = "2026-04-29T21:20:29.96Z" }, + { url = "https://files.pythonhosted.org/packages/16/e7/b3e0df641d2d5283446d7960fc407195ba722b9a0789bb0a1429bf9ee855/miniaudio-1.71-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3ef441d139264f8a5dcb9aa6fcd0b1e1e69f58715baae416ff33f045ffba6ad5", size = 378418, upload-time = "2026-04-29T21:20:31.341Z" }, + { url = "https://files.pythonhosted.org/packages/66/ea/f5940232d0c83777562e802f376a841046d78e94753450fb9a6685a44190/miniaudio-1.71-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:84139a10ef172acd762ccf120142877b037a1aaf71def99d2c75f66329f89d8b", size = 351473, upload-time = "2026-04-29T21:20:32.464Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a6/6b5ae21b74fe70da935de389e01b3cce86c790ca4083f6a63c3ee922673e/miniaudio-1.71-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a28ff4ad23e55bbde8808ce525d3bb7d249d7612f77646b30e06fc6b7a778ac", size = 643683, upload-time = "2026-04-29T21:20:33.598Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/ef851e2e1d9dfde2b97cc053f0d79c6612088b27044698ed5d8c687f05de/miniaudio-1.71-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:33986d5d725ebcbc253551e7358689bc81b19b6950b33cec8e8c1142ca4fc0a9", size = 645203, upload-time = "2026-04-29T21:20:34.713Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4a/0da61fea8b8469d51b77d43846572ef9255d54c9b6b65a552446bbd55f90/miniaudio-1.71-cp314-cp314-win32.whl", hash = "sha256:3bbeb1e068fe42475e017e8150e9e345182b583d0dd4d9e77ffa20c39935d9ec", size = 241126, upload-time = "2026-04-29T21:20:35.975Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d0/ad7bfa63e1baacd2d4803ee04862bb06fcfbbb34a340bf2bf3979e0068dc/miniaudio-1.71-cp314-cp314-win_amd64.whl", hash = "sha256:154b085dd914a0e79e3d93160e1a07aacb27d66c65f9ef6a0d87c1a194f32c04", size = 281740, upload-time = "2026-04-29T21:20:37.07Z" }, +] + [[package]] name = "mlx" version = "0.31.2" @@ -659,6 +688,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/6f/da48d2d7a76e644d35438ef6f33c68755fdd382e2c546fd1804ccba01d04/mlx-0.31.2-cp314-cp314-manylinux_2_35_x86_64.whl", hash = "sha256:69fbc94bf53607a75af9eb3e22c354738a6fe4e25aa4e2b20934b009a4bba1f3", size = 685459, upload-time = "2026-04-22T03:15:00.45Z" }, ] +[[package]] +name = "mlx-audio" +version = "0.4.3" +source = { git = "https://github.com/Blaizzy/mlx-audio.git?rev=14add666b5313cadff94a231ee11979f6ac1adf7#14add666b5313cadff94a231ee11979f6ac1adf7" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "miniaudio" }, + { name = "mlx" }, + { name = "mlx-lm" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "sounddevice" }, + { name = "tqdm" }, + { name = "transformers" }, +] + +[[package]] +name = "mlx-lm" +version = "0.31.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "mlx", marker = "sys_platform == 'darwin'" }, + { name = "numpy" }, + { name = "protobuf" }, + { name = "pyyaml" }, + { name = "sentencepiece" }, + { name = "transformers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/94/9a38d6b0c6fcca995b9136c94eb7da1e9c5165652edf228b96b29960fa7a/mlx_lm-0.31.3.tar.gz", hash = "sha256:61eb0e3ba09444f77f874aff295401d7ccd20b39495cbbce0c782a15474ce733", size = 304318, upload-time = "2026-04-22T07:37:27.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/02/9a67b8e4f87e3e2e5cd7b1ad79304b93c09a0db6af34bee75e6551c06c60/mlx_lm-0.31.3-py3-none-any.whl", hash = "sha256:758cfddf1180053b7613db76fad3d246a331a2a905808e1164a275621fc983b8", size = 408890, upload-time = "2026-04-22T07:37:25.965Z" }, +] + [[package]] name = "mlx-metal" version = "0.31.2" @@ -1028,7 +1091,7 @@ wheels = [ [[package]] name = "pipecat-local-stt-server" -version = "0.2.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "websockets" }, @@ -1051,10 +1114,14 @@ parakeet = [ [package.dev-dependencies] dev = [ + { name = "numpy" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, ] +nemotron = [ + { name = "mlx-audio" }, +] [package.metadata] requires-dist = [ @@ -1071,10 +1138,12 @@ provides-extras = ["client", "mlx", "parakeet"] [package.metadata.requires-dev] dev = [ + { name = "numpy", specifier = ">=1.26" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "ruff", specifier = ">=0.8.0" }, ] +nemotron = [{ name = "mlx-audio", git = "https://github.com/Blaizzy/mlx-audio.git?rev=14add666b5313cadff94a231ee11979f6ac1adf7" }] [[package]] name = "platformdirs" @@ -1108,6 +1177,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/2d/d4bf65e47cea8ff2c794a600c4fd1273a7902f268757c531e0ee9f18aa58/pooch-1.9.0-py3-none-any.whl", hash = "sha256:f265597baa9f760d25ceb29d0beb8186c243d6607b0f60b83ecf14078dbc703b", size = 67175, upload-time = "2026-01-30T19:15:08.36Z" }, ] +[[package]] +name = "protobuf" +version = "7.35.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/fd/5b1491d9e4b586d621c54f4c36b888714164b6875f8d6afa3f9072906a51/protobuf-7.35.0.tar.gz", hash = "sha256:a2efd84605f41e559f1881b0912b44099d0a2ac9bf46b3474823f10fb393b0e6", size = 458677, upload-time = "2026-05-19T23:02:29.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ee/93d06e358a4aa32280b00e722d3ea0a1f25fc3cc5778d80581c9cca2c10e/protobuf-7.35.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:66be6c513931c794fa92c080ffee41671390da3d79da219cf9c0c0907f035dda", size = 433225, upload-time = "2026-05-19T23:02:19.884Z" }, + { url = "https://files.pythonhosted.org/packages/8b/39/1c76c2da93f3c507e958e0aecee2391cc44d4625de6c728bbc555195b5a8/protobuf-7.35.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:fcbe42a4ac09d3ec9c987ddfcd956afd0b15f1ff613bd8371bde9405ffd5c8e5", size = 328847, upload-time = "2026-05-19T23:02:22.3Z" }, + { url = "https://files.pythonhosted.org/packages/91/1a/39f7ce90a238c1a987a4d81ec26379e02ca0aff367de68e4a1fa474215b9/protobuf-7.35.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4cbf5cc286130e06a6c9bbefac442431173906dfcc979712183d4adcc01b37ee", size = 344030, upload-time = "2026-05-19T23:02:23.591Z" }, + { url = "https://files.pythonhosted.org/packages/70/5b/6baf9008817964454055ff3fe65f1de0b5f1e26c80c82f7fb108b7cd4ea3/protobuf-7.35.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:6c0f98f10c8a05ea30f8993dfef2de093d27b490fdae78bb60c8343795d55011", size = 327130, upload-time = "2026-05-19T23:02:24.637Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e5/e46adb0badc388bfb84877a5f9f026aff63f60e611016cf64dbe77e05446/protobuf-7.35.0-cp310-abi3-win32.whl", hash = "sha256:4c4617b83ade0e279d1d2bfe04025a1adb87f9ed657de038620dc0ff959357f6", size = 428946, upload-time = "2026-05-19T23:02:25.741Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ab/547fbd9e16d879dd13c167478f8ae0a83a428008ca07a5e06acdc23ad473/protobuf-7.35.0-cp310-abi3-win_amd64.whl", hash = "sha256:f05bcadf9a2a6b8dda047007075135fb7d08c73d9177aabc067e1be46881a201", size = 439996, upload-time = "2026-05-19T23:02:26.808Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ef/50433d346c56657a70d27f156c7b349ac59a068b01de4eb796e747eecc43/protobuf-7.35.0-py3-none-any.whl", hash = "sha256:c13f325cf242bad135c350629eeb5d54b24228eb472fb3e2e9ebbd4c5dc20ca0", size = 171659, upload-time = "2026-05-19T23:02:27.842Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1342,6 +1426,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, ] +[[package]] +name = "safetensors" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" }, + { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" }, + { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" }, + { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" }, + { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" }, +] + [[package]] name = "scikit-learn" version = "1.8.0" @@ -1447,6 +1553,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, ] +[[package]] +name = "sentencepiece" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/15/2e7a025fc62d764b151ae6d0f2a92f8081755ebe8d4a64099accc6f77ba6/sentencepiece-0.2.1.tar.gz", hash = "sha256:8138cec27c2f2282f4a34d9a016e3374cd40e5c6e9cb335063db66a0a3b71fad", size = 3228515, upload-time = "2025-08-12T07:00:51.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/be/32ce495aa1d0e0c323dcb1ba87096037358edee539cac5baf8755a6bd396/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57cae326c8727de58c85977b175af132a7138d84c764635d7e71bbee7e774133", size = 1943152, upload-time = "2025-08-12T06:59:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/88/7e/ff23008899a58678e98c6ff592bf4d368eee5a71af96d0df6b38a039dd4f/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:56dd39a3c4d6493db3cdca7e8cc68c6b633f0d4195495cbadfcf5af8a22d05a6", size = 1325651, upload-time = "2025-08-12T06:59:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/19/84/42eb3ce4796777a1b5d3699dfd4dca85113e68b637f194a6c8d786f16a04/sentencepiece-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9381351182ff9888cc80e41c632e7e274b106f450de33d67a9e8f6043da6f76", size = 1253645, upload-time = "2025-08-12T06:59:42.903Z" }, + { url = "https://files.pythonhosted.org/packages/89/fa/d3d5ebcba3cb9e6d3775a096251860c41a6bc53a1b9461151df83fe93255/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f955df238021bf11f0fc37cdb54fd5e5b5f7fd30ecc3d93fb48b6815437167", size = 1316273, upload-time = "2025-08-12T06:59:44.476Z" }, + { url = "https://files.pythonhosted.org/packages/04/88/14f2f4a2b922d8b39be45bf63d79e6cd3a9b2f248b2fcb98a69b12af12f5/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdfecef430d985f1c2bcbfff3defd1d95dae876fbd0173376012d2d7d24044b", size = 1387881, upload-time = "2025-08-12T06:59:46.09Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b8/903e5ccb77b4ef140605d5d71b4f9e0ad95d456d6184688073ed11712809/sentencepiece-0.2.1-cp312-cp312-win32.whl", hash = "sha256:a483fd29a34c3e34c39ac5556b0a90942bec253d260235729e50976f5dba1068", size = 999540, upload-time = "2025-08-12T06:59:48.023Z" }, + { url = "https://files.pythonhosted.org/packages/2d/81/92df5673c067148c2545b1bfe49adfd775bcc3a169a047f5a0e6575ddaca/sentencepiece-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:4cdc7c36234fda305e85c32949c5211faaf8dd886096c7cea289ddc12a2d02de", size = 1054671, upload-time = "2025-08-12T06:59:49.895Z" }, + { url = "https://files.pythonhosted.org/packages/fe/02/c5e3bc518655d714622bec87d83db9cdba1cd0619a4a04e2109751c4f47f/sentencepiece-0.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:daeb5e9e9fcad012324807856113708614d534f596d5008638eb9b40112cd9e4", size = 1033923, upload-time = "2025-08-12T06:59:51.952Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4a/85fbe1706d4d04a7e826b53f327c4b80f849cf1c7b7c5e31a20a97d8f28b/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dcd8161eee7b41aae57ded06272905dbd680a0a04b91edd0f64790c796b2f706", size = 1943150, upload-time = "2025-08-12T06:59:53.588Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/4cfb393e287509fc2155480b9d184706ef8d9fa8cbf5505d02a5792bf220/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c6c8f42949f419ff8c7e9960dbadcfbc982d7b5efc2f6748210d3dd53a7de062", size = 1325651, upload-time = "2025-08-12T06:59:55.073Z" }, + { url = "https://files.pythonhosted.org/packages/8d/de/5a007fb53b1ab0aafc69d11a5a3dd72a289d5a3e78dcf2c3a3d9b14ffe93/sentencepiece-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:097f3394e99456e9e4efba1737c3749d7e23563dd1588ce71a3d007f25475fff", size = 1253641, upload-time = "2025-08-12T06:59:56.562Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d2/f552be5928105588f4f4d66ee37dd4c61460d8097e62d0e2e0eec41bc61d/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7b670879c370d350557edabadbad1f6561a9e6968126e6debca4029e5547820", size = 1316271, upload-time = "2025-08-12T06:59:58.109Z" }, + { url = "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47", size = 1387882, upload-time = "2025-08-12T07:00:00.701Z" }, + { url = "https://files.pythonhosted.org/packages/ac/dd/f7774d42a881ced8e1739f393ab1e82ece39fc9abd4779e28050c2e975b5/sentencepiece-0.2.1-cp313-cp313-win32.whl", hash = "sha256:92b3816aa2339355fda2c8c4e021a5de92180b00aaccaf5e2808972e77a4b22f", size = 999541, upload-time = "2025-08-12T07:00:02.709Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e9/932b9eae6fd7019548321eee1ab8d5e3b3d1294df9d9a0c9ac517c7b636d/sentencepiece-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:10ed3dab2044c47f7a2e7b4969b0c430420cdd45735d78c8f853191fa0e3148b", size = 1054669, upload-time = "2025-08-12T07:00:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/c9/3a/76488a00ea7d6931689cda28726a1447d66bf1a4837943489314593d5596/sentencepiece-0.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac650534e2251083c5f75dde4ff28896ce7c8904133dc8fef42780f4d5588fcd", size = 1033922, upload-time = "2025-08-12T07:00:06.496Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b6/08fe2ce819e02ccb0296f4843e3f195764ce9829cbda61b7513f29b95718/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8dd4b477a7b069648d19363aad0cab9bad2f4e83b2d179be668efa672500dc94", size = 1946052, upload-time = "2025-08-12T07:00:08.136Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d9/1ea0e740591ff4c6fc2b6eb1d7510d02f3fb885093f19b2f3abd1363b402/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0c0f672da370cc490e4c59d89e12289778310a0e71d176c541e4834759e1ae07", size = 1327408, upload-time = "2025-08-12T07:00:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/99/7e/1fb26e8a21613f6200e1ab88824d5d203714162cf2883248b517deb500b7/sentencepiece-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad8493bea8432dae8d6830365352350f3b4144415a1d09c4c8cb8d30cf3b6c3c", size = 1254857, upload-time = "2025-08-12T07:00:11.021Z" }, + { url = "https://files.pythonhosted.org/packages/bc/85/c72fd1f3c7a6010544d6ae07f8ddb38b5e2a7e33bd4318f87266c0bbafbf/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b81a24733726e3678d2db63619acc5a8dccd074f7aa7a54ecd5ca33ca6d2d596", size = 1315722, upload-time = "2025-08-12T07:00:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e8/661e5bd82a8aa641fd6c1020bd0e890ef73230a2b7215ddf9c8cd8e941c2/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a81799d0a68d618e89063fb423c3001a034c893069135ffe51fee439ae474d6", size = 1387452, upload-time = "2025-08-12T07:00:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/99/5e/ae66c361023a470afcbc1fbb8da722c72ea678a2fcd9a18f1a12598c7501/sentencepiece-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:89a3ea015517c42c0341d0d962f3e6aaf2cf10d71b1932d475c44ba48d00aa2b", size = 1002501, upload-time = "2025-08-12T07:00:16.966Z" }, + { url = "https://files.pythonhosted.org/packages/c1/03/d332828c4ff764e16c1b56c2c8f9a33488bbe796b53fb6b9c4205ddbf167/sentencepiece-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:33f068c9382dc2e7c228eedfd8163b52baa86bb92f50d0488bf2b7da7032e484", size = 1057555, upload-time = "2025-08-12T07:00:18.573Z" }, + { url = "https://files.pythonhosted.org/packages/88/14/5aee0bf0864df9bd82bd59e7711362908e4935e3f9cdc1f57246b5d5c9b9/sentencepiece-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:b3616ad246f360e52c85781e47682d31abfb6554c779e42b65333d4b5f44ecc0", size = 1036042, upload-time = "2025-08-12T07:00:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/24/9c/89eb8b2052f720a612478baf11c8227dcf1dc28cd4ea4c0c19506b5af2a2/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5d0350b686c320068702116276cfb26c066dc7e65cfef173980b11bb4d606719", size = 1943147, upload-time = "2025-08-12T07:00:21.809Z" }, + { url = "https://files.pythonhosted.org/packages/82/0b/a1432bc87f97c2ace36386ca23e8bd3b91fb40581b5e6148d24b24186419/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c7f54a31cde6fa5cb030370566f68152a742f433f8d2be458463d06c208aef33", size = 1325624, upload-time = "2025-08-12T07:00:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/ea/99/bbe054ebb5a5039457c590e0a4156ed073fb0fe9ce4f7523404dd5b37463/sentencepiece-0.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c83b85ab2d6576607f31df77ff86f28182be4a8de6d175d2c33ca609925f5da1", size = 1253670, upload-time = "2025-08-12T07:00:24.69Z" }, + { url = "https://files.pythonhosted.org/packages/19/ad/d5c7075f701bd97971d7c2ac2904f227566f51ef0838dfbdfdccb58cd212/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1855f57db07b51fb51ed6c9c452f570624d2b169b36f0f79ef71a6e6c618cd8b", size = 1316247, upload-time = "2025-08-12T07:00:26.435Z" }, + { url = "https://files.pythonhosted.org/packages/fb/03/35fbe5f3d9a7435eebd0b473e09584bd3cc354ce118b960445b060d33781/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01e6912125cb45d3792f530a4d38f8e21bf884d6b4d4ade1b2de5cf7a8d2a52b", size = 1387894, upload-time = "2025-08-12T07:00:28.339Z" }, + { url = "https://files.pythonhosted.org/packages/dc/aa/956ef729aafb6c8f9c443104c9636489093bb5c61d6b90fc27aa1a865574/sentencepiece-0.2.1-cp314-cp314-win32.whl", hash = "sha256:c415c9de1447e0a74ae3fdb2e52f967cb544113a3a5ce3a194df185cbc1f962f", size = 1096698, upload-time = "2025-08-12T07:00:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/b8/cb/fe400d8836952cc535c81a0ce47dc6875160e5fedb71d2d9ff0e9894c2a6/sentencepiece-0.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:881b2e44b14fc19feade3cbed314be37de639fc415375cefaa5bc81a4be137fd", size = 1155115, upload-time = "2025-08-12T07:00:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/32/89/047921cf70f36c7b6b6390876b2399b3633ab73b8d0cb857e5a964238941/sentencepiece-0.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2005242a16d2dc3ac5fe18aa7667549134d37854823df4c4db244752453b78a8", size = 1133890, upload-time = "2025-08-12T07:00:34.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/11/5b414b9fae6255b5fb1e22e2ed3dc3a72d3a694e5703910e640ac78346bb/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a19adcec27c524cb7069a1c741060add95f942d1cbf7ad0d104dffa0a7d28a2b", size = 1946081, upload-time = "2025-08-12T07:00:36.97Z" }, + { url = "https://files.pythonhosted.org/packages/77/eb/7a5682bb25824db8545f8e5662e7f3e32d72a508fdce086029d89695106b/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e37e4b4c4a11662b5db521def4e44d4d30ae69a1743241412a93ae40fdcab4bb", size = 1327406, upload-time = "2025-08-12T07:00:38.669Z" }, + { url = "https://files.pythonhosted.org/packages/03/b0/811dae8fb9f2784e138785d481469788f2e0d0c109c5737372454415f55f/sentencepiece-0.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:477c81505db072b3ab627e7eab972ea1025331bd3a92bacbf798df2b75ea86ec", size = 1254846, upload-time = "2025-08-12T07:00:40.611Z" }, + { url = "https://files.pythonhosted.org/packages/ef/23/195b2e7ec85ebb6a547969f60b723c7aca5a75800ece6cc3f41da872d14e/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:010f025a544ef770bb395091d57cb94deb9652d8972e0d09f71d85d5a0816c8c", size = 1315721, upload-time = "2025-08-12T07:00:42.914Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/553dbe4178b5f23eb28e59393dddd64186178b56b81d9b8d5c3ff1c28395/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:733e59ff1794d26db706cd41fc2d7ca5f6c64a820709cb801dc0ea31780d64ab", size = 1387458, upload-time = "2025-08-12T07:00:44.56Z" }, + { url = "https://files.pythonhosted.org/packages/66/7c/08ff0012507297a4dd74a5420fdc0eb9e3e80f4e88cab1538d7f28db303d/sentencepiece-0.2.1-cp314-cp314t-win32.whl", hash = "sha256:d3233770f78e637dc8b1fda2cd7c3b99ec77e7505041934188a4e7fe751de3b0", size = 1099765, upload-time = "2025-08-12T07:00:46.058Z" }, + { url = "https://files.pythonhosted.org/packages/91/d5/2a69e1ce15881beb9ddfc7e3f998322f5cedcd5e4d244cb74dade9441663/sentencepiece-0.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e4366c97b68218fd30ea72d70c525e6e78a6c0a88650f57ac4c43c63b234a9d", size = 1157807, upload-time = "2025-08-12T07:00:47.673Z" }, + { url = "https://files.pythonhosted.org/packages/f3/16/54f611fcfc2d1c46cbe3ec4169780b2cfa7cf63708ef2b71611136db7513/sentencepiece-0.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:105e36e75cbac1292642045458e8da677b2342dcd33df503e640f0b457cb6751", size = 1136264, upload-time = "2025-08-12T07:00:49.485Z" }, +] + [[package]] name = "setuptools" version = "81.0.0" @@ -1465,6 +1619,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sounddevice" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" }, + { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, +] + [[package]] name = "soundfile" version = "0.13.1" @@ -1607,6 +1777,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" }, ] +[[package]] +name = "tokenizers" +version = "0.22.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" }, + { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" }, + { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" }, + { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" }, + { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" }, +] + [[package]] name = "torch" version = "2.12.0" @@ -1663,6 +1859,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, ] +[[package]] +name = "transformers" +version = "5.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, + { name = "typer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/38/d5f978bd5091019e89aef29b9a831f5cd70f2598963a3ead8b9570cab592/transformers-5.10.2.tar.gz", hash = "sha256:f9a44b9c8ca9ab1156b467f574d832ea066284299c2fd0ed84641ccb592751fc", size = 8799687, upload-time = "2026-06-04T18:43:49.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/6f/e1564b0cc182afa05e219a8e09a8e770ffaab879b6b824b56c819bd221da/transformers-5.10.2-py3-none-any.whl", hash = "sha256:8a669db546f82c7c3618cb46ceb0f0afd89292bc70f319c058f8332ec63e268d", size = 11003830, upload-time = "2026-06-04T18:43:45.303Z" }, +] + [[package]] name = "triton" version = "3.7.0"