diff --git a/.github/actions/fetch-canary/action.yml b/.github/actions/fetch-canary/action.yml index 31a99bc0..5a6ee6cd 100644 --- a/.github/actions/fetch-canary/action.yml +++ b/.github/actions/fetch-canary/action.yml @@ -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 @@ -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 @@ -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), @@ -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" diff --git a/bindings/python/README.md b/bindings/python/README.md index 1e57c9b8..094bb9cb 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -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`: diff --git a/bindings/python/src/transcribe_cpp/__init__.py b/bindings/python/src/transcribe_cpp/__init__.py index bc0a6602..c0e8bca6 100644 --- a/bindings/python/src/transcribe_cpp/__init__.py +++ b/bindings/python/src/transcribe_cpp/__init__.py @@ -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"] @@ -89,6 +91,8 @@ "KVType", "Task", "Timestamps", + "Pnc", + "Itn", "Diarize", "CommitPolicy", "Feature", @@ -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, @@ -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 " @@ -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 @@ -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 @@ -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( @@ -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, @@ -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( @@ -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, @@ -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") @@ -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, @@ -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) diff --git a/bindings/python/tests/conftest.py b/bindings/python/tests/conftest.py index f20a62bc..aeb0036d 100644 --- a/bindings/python/tests/conftest.py +++ b/bindings/python/tests/conftest.py @@ -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": @@ -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) diff --git a/bindings/python/tests/test_errors.py b/bindings/python/tests/test_errors.py index e3c8affb..47066cd3 100644 --- a/bindings/python/tests/test_errors.py +++ b/bindings/python/tests/test_errors.py @@ -13,6 +13,8 @@ from __future__ import annotations +import inspect + import pytest import transcribe_cpp as t @@ -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__ diff --git a/bindings/python/tests/test_text_controls.py b/bindings/python/tests/test_text_controls.py new file mode 100644 index 00000000..102292b1 --- /dev/null +++ b/bindings/python/tests/test_text_controls.py @@ -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 diff --git a/bindings/rust/transcribe-cpp/README.md b/bindings/rust/transcribe-cpp/README.md index 6120e70e..95f9f969 100644 --- a/bindings/rust/transcribe-cpp/README.md +++ b/bindings/rust/transcribe-cpp/README.md @@ -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: diff --git a/bindings/rust/transcribe-cpp/tests/common/mod.rs b/bindings/rust/transcribe-cpp/tests/common/mod.rs index 4daf2ef7..3c5ebfa0 100644 --- a/bindings/rust/transcribe-cpp/tests/common/mod.rs +++ b/bindings/rust/transcribe-cpp/tests/common/mod.rs @@ -60,9 +60,9 @@ pub fn smoke_streaming_model() -> Option { 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 { ensure_backends(); let path = std::env::var_os(env_var) @@ -95,6 +95,22 @@ pub fn smoke_voxtral_model() -> Option { ) } +/// Canary model whose generic PNC run parameter changes the prompt. +pub fn smoke_pnc_model() -> Option { + 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 { + 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)> { diff --git a/bindings/rust/transcribe-cpp/tests/no_model.rs b/bindings/rust/transcribe-cpp/tests/no_model.rs index a74e3736..89a750be 100644 --- a/bindings/rust/transcribe-cpp/tests/no_model.rs +++ b/bindings/rust/transcribe-cpp/tests/no_model.rs @@ -5,7 +5,8 @@ mod common; use transcribe_cpp::{ abi_struct_size, backend_available, compiled_version, device_count, devices, header_hash, - init_backends_default, version, AbiStruct, Backend, DeviceType, Error, Model, + init_backends_default, version, AbiStruct, Backend, DeviceType, Error, Itn, Model, Pnc, + RunOptions, }; #[test] @@ -38,6 +39,17 @@ fn abi_struct_sizes_are_live() { } } +#[test] +fn generic_text_control_options_round_trip() { + let options = RunOptions { + pnc: Pnc::Off, + itn: Itn::On, + ..Default::default() + }; + assert_eq!(options.pnc, Pnc::Off); + assert_eq!(options.itn, Itn::On); +} + #[test] fn at_least_a_cpu_device() { // A compiled-in build has the CPU backend registered already; a diff --git a/bindings/rust/transcribe-cpp/tests/transcribe.rs b/bindings/rust/transcribe-cpp/tests/transcribe.rs index 067cc95d..c430b7fb 100644 --- a/bindings/rust/transcribe-cpp/tests/transcribe.rs +++ b/bindings/rust/transcribe-cpp/tests/transcribe.rs @@ -7,7 +7,7 @@ mod common; use std::sync::Arc; use std::thread; -use transcribe_cpp::{Error, Model, RunOptions, TimestampKind}; +use transcribe_cpp::{Error, Feature, Itn, Model, Pnc, RunOptions, TimestampKind}; #[test] fn transcribes_jfk_with_segments() { @@ -72,6 +72,69 @@ fn capabilities_and_identity() { assert!(caps.native_sample_rate > 0, "{caps:?}"); } +#[test] +fn pnc_changes_canary_prompt() { + let (Some(model_path), Some(pcm)) = (common::smoke_pnc_model(), common::smoke_audio()) else { + eprintln!("skip pnc_changes_canary_prompt: PNC model/audio unavailable"); + return; + }; + let model = Model::load(model_path).unwrap(); + assert!(model.supports(Feature::Pnc)); + let mut session = model.session().unwrap(); + let run = |session: &mut transcribe_cpp::Session, pnc| { + session + .run( + &pcm, + &RunOptions { + language: Some("en".into()), + pnc, + ..Default::default() + }, + ) + .unwrap() + .text + }; + let default = run(&mut session, Pnc::Default); + let enabled = run(&mut session, Pnc::On); + let disabled = run(&mut session, Pnc::Off); + assert_eq!(default, enabled); + assert_ne!(disabled, enabled); + assert_eq!(disabled, disabled.to_lowercase()); +} + +#[test] +fn itn_changes_sensevoice_text_normalization() { + let (Some(model_path), Some(pcm)) = (common::smoke_itn_model(), common::smoke_audio()) else { + eprintln!("skip itn_changes_sensevoice_text_normalization: ITN model/audio unavailable"); + return; + }; + let model = Model::load(model_path).unwrap(); + assert!(model.supports(Feature::Itn)); + let mut session = model.session().unwrap(); + let run = |session: &mut transcribe_cpp::Session, itn| { + session + .run( + &pcm, + &RunOptions { + language: Some("en".into()), + itn, + ..Default::default() + }, + ) + .unwrap() + }; + let default = run(&mut session, Itn::Default); + let disabled = run(&mut session, Itn::Off); + let enabled = run(&mut session, Itn::On); + assert_eq!( + (default.text, default.raw_text), + (disabled.text.clone(), disabled.raw_text.clone()) + ); + assert_ne!(enabled.text, disabled.text); + assert!(disabled.raw_text.contains("<|woitn|>")); + assert!(enabled.raw_text.contains("<|withitn|>")); +} + #[test] fn session_limits_are_sane() { let Some((model_path, _)) = common::smoke_fixtures("session_limits_are_sane") else { diff --git a/bindings/swift/README.md b/bindings/swift/README.md index 32ac0590..fd34d7b6 100644 --- a/bindings/swift/README.md +++ b/bindings/swift/README.md @@ -60,6 +60,18 @@ for segment in transcript.segments { `run` is blocking; `try await session.run(pcm)` uses the async convenience overload and hops the work off the caller's thread. +### Punctuation, capitalization, and text normalization + +`RunOptions.pnc` and `RunOptions.itn` default to preserving each model family's +shipped behavior. Probe `model.supports(.pnc)` or `.itn` before selecting +`.off`/`.on`. The same `RunOptions` is accepted by single runs, batches, +streams, and `Transcribe.transcribe`. + +```swift +let options = RunOptions(pnc: .off, itn: .on) +let transcript = try session.run(pcm, options: options) +``` + Streaming models expose committed/tentative text for UI display: ```swift diff --git a/bindings/swift/Tests/TranscribeCppTests/NoModelTests.swift b/bindings/swift/Tests/TranscribeCppTests/NoModelTests.swift index 33d8d617..9de46724 100644 --- a/bindings/swift/Tests/TranscribeCppTests/NoModelTests.swift +++ b/bindings/swift/Tests/TranscribeCppTests/NoModelTests.swift @@ -90,12 +90,14 @@ final class NoModelTests: XCTestCase { // not shadow Swift's concurrency `Task`). Lock the public name + `task:` // option here so an accidental rename is caught without a model. func testTranscriptionTaskOptionRoundTrips() { - let translate = RunOptions(task: .translate, diarize: .on) + let translate = RunOptions(task: .translate, pnc: .off, itn: .on, diarize: .on) guard case .translate = translate.task else { return XCTFail("task option did not round-trip to .translate") } let task: TranscriptionTask = .transcribe guard case .transcribe = task else { return XCTFail("TranscriptionTask.transcribe") } + guard case .off = translate.pnc else { return XCTFail("Pnc.off") } + guard case .on = translate.itn else { return XCTFail("Itn.on") } guard case .on = translate.diarize else { return XCTFail("Diarize.on") } } diff --git a/bindings/swift/Tests/TranscribeCppTests/TestSupport.swift b/bindings/swift/Tests/TranscribeCppTests/TestSupport.swift index 34dd2e15..43eff078 100644 --- a/bindings/swift/Tests/TranscribeCppTests/TestSupport.swift +++ b/bindings/swift/Tests/TranscribeCppTests/TestSupport.swift @@ -46,9 +46,9 @@ enum Fixtures { return FileManager.default.fileExists(atPath: path) ? path : nil } - // Extra family-extension canaries — not in the CI fetch-canary set, so these - // gate on their own env var / in-repo GGUF and XCTSkip when absent (they run - // locally where the GGUFs exist; CI skips them). + // Feature-specific canaries use an env override or in-repo GGUF and skip + // when absent. CI exports the lightweight shared canaries; heavyweight + // models such as Voxtral remain local-only. private static func familyModel(_ envKey: String, _ relativePath: String) -> String? { if let override = env(envKey) { return override } let path = repoRoot().appendingPathComponent(relativePath).path @@ -73,6 +73,18 @@ enum Fixtures { "TRANSCRIBE_SMOKE_VOXTRAL_MODEL", "models/Voxtral-Mini-4B-Realtime-2602/Voxtral-Mini-4B-Realtime-2602-Q4_K_M.gguf") } + /// Canary model whose generic PNC run parameter changes the prompt. + static func pncModelPath() -> String? { + familyModel( + "TRANSCRIBE_SMOKE_PNC_MODEL", + "models/canary-180m-flash/canary-180m-flash-Q8_0.gguf") + } + /// SenseVoice model whose generic ITN parameter changes text normalization. + static func itnModelPath() -> String? { + familyModel( + "TRANSCRIBE_SMOKE_ITN_MODEL", + "models/SenseVoiceSmall/SenseVoiceSmall-Q8_0.gguf") + } /// The model path + decoded PCM, or `XCTSkip` when either is absent. static func modelAndAudio() throws -> (model: String, pcm: [Float]) { diff --git a/bindings/swift/Tests/TranscribeCppTests/TranscribeTests.swift b/bindings/swift/Tests/TranscribeCppTests/TranscribeTests.swift index 69510255..3be8d400 100644 --- a/bindings/swift/Tests/TranscribeCppTests/TranscribeTests.swift +++ b/bindings/swift/Tests/TranscribeCppTests/TranscribeTests.swift @@ -64,6 +64,46 @@ final class TranscribeTests: XCTestCase { XCTAssertGreaterThan(model.capabilities.nativeSampleRate, 0) } + func testPncChangesCanaryPrompt() throws { + guard let path = Fixtures.pncModelPath(), let audio = Fixtures.audioPath() else { + throw XCTSkip("PNC model/audio unavailable") + } + let model = try Model(path: path, options: ModelOptions(backend: .cpu)) + XCTAssertTrue(model.supports(.pnc)) + let session = try model.session() + let pcm = try Fixtures.loadWav(audio) + let run = { (pnc: Pnc) in + try session.run(pcm, options: RunOptions(pnc: pnc, language: "en")).text + } + let defaultText = try run(.default) + let enabled = try run(.on) + let disabled = try run(.off) + XCTAssertEqual(defaultText, enabled) + XCTAssertNotEqual(disabled, enabled) + XCTAssertEqual(disabled, disabled.lowercased()) + } + + func testItnChangesSenseVoiceTextNormalization() throws { + guard let path = Fixtures.itnModelPath(), let audio = Fixtures.audioPath() else { + throw XCTSkip("ITN model/audio unavailable") + } + let model = try Model(path: path, options: ModelOptions(backend: .cpu)) + XCTAssertTrue(model.supports(.itn)) + let session = try model.session() + let pcm = try Fixtures.loadWav(audio) + let run = { (itn: Itn) in + try session.run(pcm, options: RunOptions(itn: itn, language: "en")) + } + let defaultResult = try run(.default) + let disabled = try run(.off) + let enabled = try run(.on) + XCTAssertEqual(defaultResult.text, disabled.text) + XCTAssertEqual(defaultResult.rawText, disabled.rawText) + XCTAssertNotEqual(enabled.text, disabled.text) + XCTAssertTrue(disabled.rawText.contains("<|woitn|>")) + XCTAssertTrue(enabled.rawText.contains("<|withitn|>")) + } + func testSessionLimitsAreSane() throws { guard let path = Fixtures.modelPath() else { throw XCTSkip("no canary model") } let limits = try Model(path: path).session().limits diff --git a/bindings/typescript/README.md b/bindings/typescript/README.md index a7112f59..416a45ee 100644 --- a/bindings/typescript/README.md +++ b/bindings/typescript/README.md @@ -35,6 +35,17 @@ for (const seg of result.segments) { model.dispose(); ``` +### Punctuation, capitalization, and text normalization + +`pnc` and `itn` default to `"default"`, preserving each model family's shipped +behavior. Probe `model.supports("pnc")` or `model.supports("itn")` before +selecting `"off"`/`"on"`. Both options are available on single runs, batches, +and streams. + +```ts +const result = await model.transcribe(pcm, { pnc: "off", itn: "on" }); +``` + ### Streaming ```ts diff --git a/bindings/typescript/package-lock.json b/bindings/typescript/package-lock.json index 413076d2..ff381be2 100644 --- a/bindings/typescript/package-lock.json +++ b/bindings/typescript/package-lock.json @@ -250,6 +250,71 @@ "url": "https://liberapay.com/Koromix" } }, + "node_modules/@transcribe-cpp/darwin-arm64-metal": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@transcribe-cpp/darwin-arm64-metal/-/darwin-arm64-metal-0.2.1.tgz", + "integrity": "sha512-StvvbrSGNDwMN8UC03muvZkECdchyW8CRjtpj3eP1Wi9JGfxQcUE8PRCH1raDMdAorp0L9lmXMnNKt/aMIo+mA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@transcribe-cpp/darwin-x64-cpu": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@transcribe-cpp/darwin-x64-cpu/-/darwin-x64-cpu-0.2.1.tgz", + "integrity": "sha512-dk+snKC7ENo4nQfSwjkWW9Ptv2aSmJA02LRDX26FERc+K6bL9R7eiQst+JET/JfT1sVn8UESnrBOF1oBKyxUJQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@transcribe-cpp/linux-arm64-cpu-vulkan": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@transcribe-cpp/linux-arm64-cpu-vulkan/-/linux-arm64-cpu-vulkan-0.2.1.tgz", + "integrity": "sha512-c/wSkZfbQmsjqHnu2AXZT81uUpHPJW1Hxu35xp8hZiHpVbUw94zds0xbBtY8c66Nw5dFU2Y+XwhgEDil28cXqA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@transcribe-cpp/linux-x64-cpu-vulkan": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@transcribe-cpp/linux-x64-cpu-vulkan/-/linux-x64-cpu-vulkan-0.2.1.tgz", + "integrity": "sha512-0sCMolWceToa9QYN5AizAn0cCserEPM7hRf1zETHKY4UQgVZzeRaFJETLrz6YLhgEiIRpr9d7osV8mp7O4e/Ww==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@transcribe-cpp/win32-x64-cpu-vulkan": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@transcribe-cpp/win32-x64-cpu-vulkan/-/win32-x64-cpu-vulkan-0.2.1.tgz", + "integrity": "sha512-wHaKJY2XRjL/J2p6YNvLE1Oy+b0MejdyHmx1PvksRQssIivkWSITdHMpd6VseokJsYO+pKj+OwqVrIycJas0yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@types/node": { "version": "22.19.21", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", diff --git a/bindings/typescript/src/index.ts b/bindings/typescript/src/index.ts index 73fcb57a..54a651e8 100644 --- a/bindings/typescript/src/index.ts +++ b/bindings/typescript/src/index.ts @@ -36,9 +36,11 @@ import type { ExtSlot, FamilyExtension, Feature, + Itn, KvType, ModelOptions, PcmLike, + Pnc, Segment, SpeakerSegment, SessionLimits, @@ -90,6 +92,16 @@ const TIMESTAMPS: Record = { const TIMESTAMP_NAMES: Record = Object.fromEntries( Object.entries(TIMESTAMPS).map(([k, v]) => [v, k as TimestampKind]), ); +const PNC: Record = { + default: g.TRANSCRIBE_PNC_MODE_DEFAULT, + off: g.TRANSCRIBE_PNC_MODE_OFF, + on: g.TRANSCRIBE_PNC_MODE_ON, +}; +const ITN: Record = { + default: g.TRANSCRIBE_ITN_MODE_DEFAULT, + off: g.TRANSCRIBE_ITN_MODE_OFF, + on: g.TRANSCRIBE_ITN_MODE_ON, +}; const DIARIZE: Record = { default: g.TRANSCRIBE_DIARIZE_MODE_DEFAULT, off: g.TRANSCRIBE_DIARIZE_MODE_OFF, @@ -828,6 +840,8 @@ export class Session { // whisper resolves it to "segment" (its robust path), no-timestamp // families resolve to "none". p.timestamps = lookup(TIMESTAMPS, opts.timestamps ?? "auto", "timestamps"); + p.pnc = lookup(PNC, opts.pnc ?? "default", "pnc"); + p.itn = lookup(ITN, opts.itn ?? "default", "itn"); p.diarize = lookup(DIARIZE, opts.diarize ?? "default", "diarize"); if (opts.language !== undefined) p.language = opts.language; if (opts.targetLanguage !== undefined) @@ -929,6 +943,8 @@ export class Session { language: opts.language, targetLanguage: opts.targetLanguage, timestamps: opts.timestamps, + pnc: opts.pnc, + itn: opts.itn, diarize: opts.diarize, keepSpecialTags: opts.keepSpecialTags, specKDrafts: -1, diff --git a/bindings/typescript/src/types.ts b/bindings/typescript/src/types.ts index 4caafe65..0a439518 100644 --- a/bindings/typescript/src/types.ts +++ b/bindings/typescript/src/types.ts @@ -6,6 +6,8 @@ export type Backend = "auto" | "cpu" | "cpu_accel" | "cuda" | "rocm" | "vulkan" export type KvType = "auto" | "f32" | "f16"; export type Task = "transcribe" | "translate"; export type TimestampKind = "none" | "auto" | "segment" | "word" | "token"; +export type Pnc = "default" | "off" | "on"; +export type Itn = "default" | "off" | "on"; export type Diarize = "default" | "off" | "on"; export type Feature = | "initial_prompt" @@ -153,6 +155,10 @@ export interface TranscribeOptions { targetLanguage?: string; /** Default "auto" (richest the model supports, per-family). */ timestamps?: TimestampKind; + /** Punctuation and capitalization control; default preserves the family default. */ + pnc?: Pnc; + /** Inverse text normalization control; default preserves the family default. */ + itn?: Itn; /** Default "default" (speaker attribution off for every family). */ diarize?: Diarize; keepSpecialTags?: boolean; @@ -202,6 +208,8 @@ export interface StreamOptions { language?: string; targetLanguage?: string; timestamps?: TimestampKind; + pnc?: Pnc; + itn?: Itn; diarize?: Diarize; keepSpecialTags?: boolean; commitPolicy?: CommitPolicy; diff --git a/bindings/typescript/test/common.mjs b/bindings/typescript/test/common.mjs index 366417ed..e26d09ca 100644 --- a/bindings/typescript/test/common.mjs +++ b/bindings/typescript/test/common.mjs @@ -13,6 +13,12 @@ export const MODEL = process.env.TRANSCRIBE_SMOKE_MODEL || ""; export const STREAMING_MODEL = process.env.TRANSCRIBE_SMOKE_STREAMING_MODEL || ""; export const PARAKEET_STREAM_MODEL = process.env.TRANSCRIBE_SMOKE_PARAKEET_STREAM_MODEL || ""; export const PARAKEET_BUFFERED_MODEL = process.env.TRANSCRIBE_SMOKE_PARAKEET_BUFFERED_MODEL || ""; +export const PNC_MODEL = + process.env.TRANSCRIBE_SMOKE_PNC_MODEL || + path.resolve(HERE, "../../../models/canary-180m-flash/canary-180m-flash-Q8_0.gguf"); +export const ITN_MODEL = + process.env.TRANSCRIBE_SMOKE_ITN_MODEL || + path.resolve(HERE, "../../../models/SenseVoiceSmall/SenseVoiceSmall-Q8_0.gguf"); // Voxtral realtime is local-only (~2.5 GB+, too heavy for the CI canary set): // its env var is NOT exported by fetch-canary, so this skips cleanly in CI. export const VOXTRAL_MODEL = process.env.TRANSCRIBE_SMOKE_VOXTRAL_MODEL || ""; diff --git a/bindings/typescript/test/transcribe.test.mjs b/bindings/typescript/test/transcribe.test.mjs index fc68eab3..f1b258e5 100644 --- a/bindings/typescript/test/transcribe.test.mjs +++ b/bindings/typescript/test/transcribe.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { modelTest, MODEL, jfk } from "./common.mjs"; +import { modelTest, ITN_MODEL, MODEL, PNC_MODEL, jfk } from "./common.mjs"; import { TranscribeModel } from "../dist/index.js"; modelTest("offline transcription returns text + detected language", MODEL, async () => { @@ -54,6 +54,57 @@ modelTest("tokenize returns a non-empty token array", MODEL, async () => { } }); +modelTest("PNC changes the Canary prompt", PNC_MODEL, async () => { + const m = await TranscribeModel.load(PNC_MODEL, { backend: "cpu" }); + try { + assert.equal(m.supports("pnc"), true); + const s = m.createSession(); + try { + await assert.rejects( + // @ts-expect-error exercised from JavaScript to prove runtime validation + () => s.run(jfk(), { pnc: "maybe" }), + /invalid pnc/, + ); + const base = await s.run(jfk(), { language: "en", pnc: "default" }); + const on = await s.run(jfk(), { language: "en", pnc: "on" }); + const off = await s.run(jfk(), { language: "en", pnc: "off" }); + assert.equal(base.text, on.text); + assert.notEqual(off.text, on.text); + assert.equal(off.text, off.text.toLowerCase()); + } finally { + s.dispose(); + } + } finally { + m.dispose(); + } +}); + +modelTest("ITN changes SenseVoice text normalization", ITN_MODEL, async () => { + const m = await TranscribeModel.load(ITN_MODEL, { backend: "cpu" }); + try { + assert.equal(m.supports("itn"), true); + const s = m.createSession(); + try { + await assert.rejects( + // @ts-expect-error exercised from JavaScript to prove runtime validation + () => s.run(jfk(), { itn: "maybe" }), + /invalid itn/, + ); + const base = await s.run(jfk(), { language: "en", itn: "default" }); + const off = await s.run(jfk(), { language: "en", itn: "off" }); + const on = await s.run(jfk(), { language: "en", itn: "on" }); + assert.deepEqual([base.text, base.rawText], [off.text, off.rawText]); + assert.notEqual(on.text, off.text); + assert.match(off.rawText, /<\|woitn\|>/); + assert.match(on.rawText, /<\|withitn\|>/); + } finally { + s.dispose(); + } + } finally { + m.dispose(); + } +}); + modelTest("one model serves many sessions", MODEL, async () => { const m = await TranscribeModel.load(MODEL); try { diff --git a/docs/bindings.md b/docs/bindings.md index 98dcd463..0eb39d29 100644 --- a/docs/bindings.md +++ b/docs/bindings.md @@ -106,6 +106,22 @@ not give up the clean transcript. It is present on single, batch, and full structured stream snapshots (and may be empty before a stream has produced a successful hypothesis). +## Generic parameter parity + +First-class high-level bindings expose every field of the generic model-load, +session, run, and stream parameter structs. Generated/raw FFI coverage is not +sufficient: an application must not need private binding internals to set a +public generic option. In particular, `transcribe_run_params::pnc` and `itn` +are typed three-state controls (`default`, `off`, `on`) and must flow through +single-run, batch, streaming, and one-shot convenience surfaces wherever those +surfaces exist. + +Every generic option needs model-free enum/type coverage (plus direct +materialization coverage where the binding architecture permits it) and, when a +supporting model exists, a model-gated behavior test. A new field added to a +generic parameter struct must update all first-class bindings and this +conformance coverage in the same change. + When adding a new family extension, update: - `include/transcribe/.h` with the typed struct, kind constant, and