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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions .github/actions/fetch-canary/action.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
name: fetch-canary
description: >
Cache + fetch the private canary GGUFs and export TRANSCRIBE_SMOKE_MODEL /
TRANSCRIBE_SMOKE_STREAMING_MODEL / the two parakeet family canaries. Skips
cleanly when hf-token is empty (forks have no secret): the model tests then
skip, exactly as before this action existed. Always fetches ALL CI canaries
(whisper-tiny + moonshine-streaming ~95 MB, plus the two parakeet 0.6b
Cache + fetch the CI canary GGUFs and export TRANSCRIBE_SMOKE_MODEL /
TRANSCRIBE_SMOKE_STREAMING_MODEL / generic PNC+ITN canaries / the two
parakeet family canaries. Skips cleanly when hf-token is empty (forks have no
secret): the model tests then skip, exactly as before this action existed.
Always fetches ALL CI canaries (whisper-tiny + moonshine-streaming ~95 MB,
Canary+SenseVoice text-control canaries ~290 MB, plus the two parakeet 0.6b
family-extension canaries — cache-aware + buffered — at Q4_K_M ~0.95 GB)
under one cache key — a per-consumer subset would let one job save the shared
key with only its subset in it, and every other consumer would re-download
Expand Down Expand Up @@ -34,7 +35,7 @@ runs:
uses: actions/cache@v5
with:
path: canary
key: canary-models-v2
key: canary-models-v3
- name: Fetch canary models (cache miss only)
if: inputs.hf-token != ''
shell: bash
Expand All @@ -47,6 +48,13 @@ runs:
[ -f canary/moonshine-streaming-tiny-Q8_0.gguf ] || \
uvx --from huggingface_hub hf download handy-computer/moonshine-streaming-tiny-gguf \
moonshine-streaming-tiny-Q8_0.gguf --local-dir canary
# Generic run-parameter canaries: PNC (Canary) and ITN (SenseVoice).
[ -f canary/canary-180m-flash-Q4_K_M.gguf ] || \
uvx --from huggingface_hub hf download handy-computer/canary-180m-flash-gguf \
canary-180m-flash-Q4_K_M.gguf --local-dir canary
[ -f canary/SenseVoiceSmall-Q4_K_M.gguf ] || \
uvx --from huggingface_hub hf download handy-computer/SenseVoiceSmall-gguf \
SenseVoiceSmall-Q4_K_M.gguf --local-dir canary
# Parakeet family-extension canaries (cache-aware + buffered streaming).
# Q4_K_M keeps the CI canary set light (~0.95 GB for the pair); the
# family-ext tests are content-lenient (assert non-empty, not accuracy),
Expand All @@ -65,5 +73,7 @@ runs:
if [ -z "$prefix" ]; then prefix="$GITHUB_WORKSPACE"; fi
echo "TRANSCRIBE_SMOKE_MODEL=$prefix/canary/whisper-tiny-Q5_K_M.gguf" >> "$GITHUB_ENV"
echo "TRANSCRIBE_SMOKE_STREAMING_MODEL=$prefix/canary/moonshine-streaming-tiny-Q8_0.gguf" >> "$GITHUB_ENV"
echo "TRANSCRIBE_SMOKE_PNC_MODEL=$prefix/canary/canary-180m-flash-Q4_K_M.gguf" >> "$GITHUB_ENV"
echo "TRANSCRIBE_SMOKE_ITN_MODEL=$prefix/canary/SenseVoiceSmall-Q4_K_M.gguf" >> "$GITHUB_ENV"
echo "TRANSCRIBE_SMOKE_PARAKEET_STREAM_MODEL=$prefix/canary/nemotron-speech-streaming-en-0.6b-Q4_K_M.gguf" >> "$GITHUB_ENV"
echo "TRANSCRIBE_SMOKE_PARAKEET_BUFFERED_MODEL=$prefix/canary/parakeet-unified-en-0.6b-Q4_K_M.gguf" >> "$GITHUB_ENV"
12 changes: 12 additions & 0 deletions bindings/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ pcm = np.asarray(audio, dtype=np.float32) # 1-D, 16 kHz mono
result = session.run(pcm)
```

### Punctuation, capitalization, and text normalization

Generic run controls use `"default"` to preserve each model family's shipped
behavior. Models advertising `model.supports("pnc")` accept `pnc="off"` or
`pnc="on"`; models advertising `model.supports("itn")` accept the equivalent
`itn` values. The options are available on `run()`, `run_batch()`, `stream()`,
and the one-shot `transcribe()` helper.

```python
result = session.run(pcm, pnc="off", itn="on")
```

Streaming models expose incremental transcription with committed/tentative
text views — see `examples/stream_wav.py`:

Expand Down
36 changes: 31 additions & 5 deletions bindings/python/src/transcribe_cpp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
KVType = Literal["auto", "f32", "f16"]
Task = Literal["transcribe", "translate"]
Timestamps = Literal["none", "auto", "segment", "word", "token"]
Pnc = Literal["default", "off", "on"]
Itn = Literal["default", "off", "on"]
Diarize = Literal["default", "off", "on"]
SortformerPreset = Literal["default", "very_high_latency", "high_latency", "low_latency"]
CommitPolicy = Literal["auto", "on_finalize", "stable_prefix"]
Expand Down Expand Up @@ -89,6 +91,8 @@
"KVType",
"Task",
"Timestamps",
"Pnc",
"Itn",
"Diarize",
"CommitPolicy",
"Feature",
Expand Down Expand Up @@ -206,6 +210,16 @@
"token": _generated.TRANSCRIBE_TIMESTAMPS_TOKEN,
}
_TIMESTAMP_NAMES = {v: k for k, v in _TIMESTAMPS.items()}
_PNC = {
"default": _generated.TRANSCRIBE_PNC_MODE_DEFAULT,
"off": _generated.TRANSCRIBE_PNC_MODE_OFF,
"on": _generated.TRANSCRIBE_PNC_MODE_ON,
}
_ITN = {
"default": _generated.TRANSCRIBE_ITN_MODE_DEFAULT,
"off": _generated.TRANSCRIBE_ITN_MODE_OFF,
"on": _generated.TRANSCRIBE_ITN_MODE_ON,
}
_DIARIZE = {
"default": _generated.TRANSCRIBE_DIARIZE_MODE_DEFAULT,
"off": _generated.TRANSCRIBE_DIARIZE_MODE_OFF,
Expand Down Expand Up @@ -638,7 +652,8 @@ def _stream_update_from(u) -> StreamUpdate:


def _build_run_params(task, language, target_language, timestamps,
keep_special_tags, spec_k_drafts, diarize="default"):
keep_special_tags, spec_k_drafts, diarize="default",
pnc="default", itn="default"):
if not isinstance(spec_k_drafts, int) or spec_k_drafts < -1:
raise InvalidArgument(
f"spec_k_drafts must be -1 (family default), 0 (disabled), or a "
Expand All @@ -648,6 +663,8 @@ def _build_run_params(task, language, target_language, timestamps,
_lib.transcribe_run_params_init(_byref(params))
params.task = _enum(_TASKS, task, "task")
params.timestamps = _enum(_TIMESTAMPS, timestamps, "timestamps")
params.pnc = _enum(_PNC, pnc, "pnc")
params.itn = _enum(_ITN, itn, "itn")
params.diarize = _enum(_DIARIZE, diarize, "diarize")
params.language = language.encode("utf-8") if language else None
params.target_language = target_language.encode("utf-8") if target_language else None
Expand Down Expand Up @@ -1079,12 +1096,16 @@ def run(self, pcm: PCMLike, *, task: Task = "transcribe",
language: str | None = None,
target_language: str | None = None,
timestamps: Timestamps = "auto",
pnc: Pnc = "default",
itn: Itn = "default",
diarize: Diarize = "default",
keep_special_tags: bool = False,
spec_k_drafts: int = -1,
family: FamilyExtension | None = None) -> Result:
"""Transcribe 16 kHz mono float32 PCM and return a materialized Result.

``pnc`` controls punctuation/capitalization and ``itn`` controls
inverse text normalization on models advertising those features.
``family`` is an optional family-specific extension (e.g.
WhisperRunOptions) carrying per-run knobs for models that accept it.
``spec_k_drafts`` tunes speculative decoding on models whose
Expand All @@ -1097,7 +1118,7 @@ def run(self, pcm: PCMLike, *, task: Task = "transcribe",
self._cancel.clear()
array, n_samples = _pcm_to_carray(pcm)
params = _build_run_params(task, language, target_language, timestamps,
keep_special_tags, spec_k_drafts, diarize)
keep_special_tags, spec_k_drafts, diarize, pnc, itn)
ext = self._resolve_family(family, "run") if family is not None else None
if ext is not None:
params.family = ctypes.cast(
Expand All @@ -1116,6 +1137,8 @@ def run_batch(self, pcms: Sequence[PCMLike], *, task: Task = "transcribe",
language: str | None = None,
target_language: str | None = None,
timestamps: Timestamps = "auto",
pnc: Pnc = "default",
itn: Itn = "default",
diarize: Diarize = "default",
keep_special_tags: bool = False,
spec_k_drafts: int = -1,
Expand Down Expand Up @@ -1153,7 +1176,7 @@ def run_batch(self, pcms: Sequence[PCMLike], *, task: Task = "transcribe",
counts[k] = n

params = _build_run_params(task, language, target_language, timestamps,
keep_special_tags, spec_k_drafts, diarize)
keep_special_tags, spec_k_drafts, diarize, pnc, itn)
ext = self._resolve_family(family, "run") if family is not None else None
if ext is not None:
params.family = ctypes.cast(
Expand Down Expand Up @@ -1204,6 +1227,7 @@ def run_batch(self, pcms: Sequence[PCMLike], *, task: Task = "transcribe",

def stream(self, *, task: Task = "transcribe", language: str | None = None,
target_language: str | None = None, timestamps: Timestamps = "none",
pnc: Pnc = "default", itn: Itn = "default",
diarize: Diarize = "default",
keep_special_tags: bool = False, commit_policy: CommitPolicy = "auto",
stable_prefix_agreement_n: int = 0,
Expand All @@ -1219,7 +1243,7 @@ def stream(self, *, task: Task = "transcribe", language: str | None = None,
# spec_k_drafts is an offline-decode knob; streaming always uses the
# family default (-1).
run_params = _build_run_params(task, language, target_language, timestamps,
keep_special_tags, -1, diarize)
keep_special_tags, -1, diarize, pnc, itn)
sp = _StreamParams()
_lib.transcribe_stream_params_init(_byref(sp))
sp.commit_policy = _enum(_COMMIT_POLICIES, commit_policy, "commit_policy")
Expand Down Expand Up @@ -1466,6 +1490,8 @@ def transcribe(
language: str | None = None,
target_language: str | None = None,
timestamps: Timestamps = "auto",
pnc: Pnc = "default",
itn: Itn = "default",
diarize: Diarize = "default",
keep_special_tags: bool = False,
spec_k_drafts: int = -1,
Expand All @@ -1482,7 +1508,7 @@ def transcribe(
"""
session_opts = dict(n_threads=n_threads, kv_type=kv_type, n_ctx=n_ctx)
run_opts = dict(task=task, language=language, target_language=target_language,
timestamps=timestamps, diarize=diarize,
timestamps=timestamps, pnc=pnc, itn=itn, diarize=diarize,
keep_special_tags=keep_special_tags,
spec_k_drafts=spec_k_drafts, family=family)

Expand Down
14 changes: 14 additions & 0 deletions bindings/python/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@
REPO
/ "models/Voxtral-Mini-4B-Realtime-2602/Voxtral-Mini-4B-Realtime-2602-Q4_K_M.gguf"
)
PNC_MODEL = REPO / "models/canary-180m-flash/canary-180m-flash-Q8_0.gguf"
ITN_MODEL = REPO / "models/SenseVoiceSmall/SenseVoiceSmall-Q8_0.gguf"


def load_wav(path: Path) -> "array.array":
Expand Down Expand Up @@ -157,3 +159,15 @@ def parakeet_buffered_model_path() -> Path:
def voxtral_model_path() -> Path:
"""Voxtral realtime streaming canary (accepts VOXTRAL_REALTIME_STREAM)."""
return _family_model("TRANSCRIBE_SMOKE_VOXTRAL_MODEL", VOXTRAL_MODEL)


@pytest.fixture(scope="session")
def pnc_model_path() -> Path:
"""Canary model whose generic PNC run parameter changes the prompt."""
return _family_model("TRANSCRIBE_SMOKE_PNC_MODEL", PNC_MODEL)


@pytest.fixture(scope="session")
def itn_model_path() -> Path:
"""SenseVoice model whose generic ITN parameter changes text normalization."""
return _family_model("TRANSCRIBE_SMOKE_ITN_MODEL", ITN_MODEL)
52 changes: 52 additions & 0 deletions bindings/python/tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

from __future__ import annotations

import inspect

import pytest

import transcribe_cpp as t
Expand Down Expand Up @@ -102,3 +104,53 @@ def test_invalid_spec_k_drafts_rejected_before_native_call():
_build_run_params("transcribe", None, None, "none", False, -2)
with pytest.raises(t.InvalidArgument, match="spec_k_drafts"):
_build_run_params("transcribe", None, None, "none", False, "many")


def test_pnc_and_itn_modes_map_to_native_run_params():
from transcribe_cpp import _build_run_params, _generated

modes = {
"default": (
_generated.TRANSCRIBE_PNC_MODE_DEFAULT,
_generated.TRANSCRIBE_ITN_MODE_DEFAULT,
),
"off": (
_generated.TRANSCRIBE_PNC_MODE_OFF,
_generated.TRANSCRIBE_ITN_MODE_OFF,
),
"on": (
_generated.TRANSCRIBE_PNC_MODE_ON,
_generated.TRANSCRIBE_ITN_MODE_ON,
),
}
for mode, (pnc, itn) in modes.items():
params = _build_run_params(
"transcribe", None, None, "none", False, -1, pnc=mode, itn=mode
)
assert params.pnc == pnc
assert params.itn == itn

for name in ("pnc", "itn"):
with pytest.raises(t.InvalidArgument, match=name):
_build_run_params(
"transcribe", None, None, "none", False, -1, **{name: "maybe"}
)


def test_public_run_surfaces_cover_every_generic_option():
common = {
"task", "language", "target_language", "timestamps", "pnc", "itn",
"diarize", "keep_special_tags", "family",
}
expected = {
t.Session.run: common | {"spec_k_drafts"},
t.Session.run_batch: common | {"spec_k_drafts"},
# Speculative decoding is explicitly offline-only.
t.Session.stream: common,
t.transcribe: common | {"spec_k_drafts"},
}
for callable_, required in expected.items():
parameters = inspect.signature(callable_).parameters
assert required <= parameters.keys()
assert "Pnc" in t.__all__
assert "Itn" in t.__all__
28 changes: 28 additions & 0 deletions bindings/python/tests/test_text_controls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Model-gated generic punctuation/capitalization and ITN controls."""

import transcribe_cpp as t


def test_pnc_changes_canary_prompt(pnc_model_path, audio_pcm):
with t.Model(pnc_model_path, backend="cpu") as model, model.session() as session:
assert model.supports("pnc")
default = session.run(audio_pcm, language="en", pnc="default").text
enabled = session.run(audio_pcm, language="en", pnc="on").text
disabled = session.run(audio_pcm, language="en", pnc="off").text

assert default == enabled
assert disabled != enabled
assert disabled == disabled.lower()


def test_itn_changes_sensevoice_text_normalization(itn_model_path, audio_pcm):
with t.Model(itn_model_path, backend="cpu") as model, model.session() as session:
assert model.supports("itn")
default = session.run(audio_pcm, language="en", itn="default")
disabled = session.run(audio_pcm, language="en", itn="off")
enabled = session.run(audio_pcm, language="en", itn="on")

assert (default.text, default.raw_text) == (disabled.text, disabled.raw_text)
assert enabled.text != disabled.text
assert "<|woitn|>" in disabled.raw_text
assert "<|withitn|>" in enabled.raw_text
14 changes: 14 additions & 0 deletions bindings/rust/transcribe-cpp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ println!("{}", result.text);
# Ok::<(), transcribe_cpp::Error>(())
```

### Punctuation, capitalization, and text normalization

`RunOptions::pnc` and `RunOptions::itn` default to preserving each model
family's shipped behavior. Probe `model.supports(Feature::Pnc)` or
`Feature::Itn` before selecting `Pnc::Off`/`On` or `Itn::Off`/`On`. The same
`RunOptions` is used by single runs, batches, streams, and `transcribe()`.

```rust
use transcribe_cpp::{Itn, Pnc, RunOptions};
let options = RunOptions { pnc: Pnc::Off, itn: Itn::On, ..Default::default() };
let result = session.run(&pcm, &options)?;
# Ok::<(), transcribe_cpp::Error>(())
```

Streaming exposes both UI-stable text and a fully materialized structured
snapshot:

Expand Down
22 changes: 19 additions & 3 deletions bindings/rust/transcribe-cpp/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ pub fn smoke_streaming_model() -> Option<PathBuf> {
path.is_file().then_some(path)
}

/// A per-family streaming-extension canary: env override or the in-repo GGUF,
/// `None` when neither is present (clean skip). Not in the CI fetch-canary set,
/// so these run locally and skip in CI.
/// A feature-specific canary: env override or the in-repo GGUF, `None` when
/// neither is present (clean skip). CI exports the lightweight shared canaries;
/// heavyweight models such as Voxtral remain local-only.
fn family_model(env_var: &str, default_rel: &str) -> Option<PathBuf> {
ensure_backends();
let path = std::env::var_os(env_var)
Expand Down Expand Up @@ -95,6 +95,22 @@ pub fn smoke_voxtral_model() -> Option<PathBuf> {
)
}

/// Canary model whose generic PNC run parameter changes the prompt.
pub fn smoke_pnc_model() -> Option<PathBuf> {
family_model(
"TRANSCRIBE_SMOKE_PNC_MODEL",
"models/canary-180m-flash/canary-180m-flash-Q8_0.gguf",
)
}

/// SenseVoice model whose generic ITN parameter changes text normalization.
pub fn smoke_itn_model() -> Option<PathBuf> {
family_model(
"TRANSCRIBE_SMOKE_ITN_MODEL",
"models/SenseVoiceSmall/SenseVoiceSmall-Q8_0.gguf",
)
}

/// Both fixtures together; prints a skip note and returns `None` if either is
/// missing (so the caller can `return` early — the Rust equivalent of skip).
pub fn smoke_fixtures(test: &str) -> Option<(PathBuf, Vec<f32>)> {
Expand Down
Loading
Loading