diff --git a/bindings/python/src/transcribe_cpp/__init__.py b/bindings/python/src/transcribe_cpp/__init__.py index 946cf10f..d536c654 100644 --- a/bindings/python/src/transcribe_cpp/__init__.py +++ b/bindings/python/src/transcribe_cpp/__init__.py @@ -58,7 +58,7 @@ CommitPolicy = Literal["auto", "on_finalize", "stable_prefix"] Feature = Literal[ "initial_prompt", "temperature_fallback", "long_form", - "cancellation", "pnc", "itn", "diarization", + "cancellation", "pnc", "itn", "diarization", "hotwords", ] __all__ = [ @@ -233,6 +233,7 @@ "pnc": _generated.TRANSCRIBE_FEATURE_PNC, "itn": _generated.TRANSCRIBE_FEATURE_ITN, "diarization": _generated.TRANSCRIBE_FEATURE_DIARIZATION, + "hotwords": _generated.TRANSCRIBE_FEATURE_HOTWORDS, } @@ -621,7 +622,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", + hotwords=None): 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 " @@ -636,6 +638,7 @@ def _build_run_params(task, language, target_language, timestamps, params.target_language = target_language.encode("utf-8") if target_language else None params.keep_special_tags = keep_special_tags params.spec_k_drafts = spec_k_drafts + params.hotwords = hotwords.encode("utf-8") if hotwords else None return params @@ -1059,6 +1062,7 @@ def run(self, pcm: PCMLike, *, task: Task = "transcribe", diarize: Diarize = "default", keep_special_tags: bool = False, spec_k_drafts: int = -1, + hotwords: str | None = None, family: FamilyExtension | None = None) -> Result: """Transcribe 16 kHz mono float32 PCM and return a materialized Result. @@ -1067,6 +1071,9 @@ def run(self, pcm: PCMLike, *, task: Task = "transcribe", ``spec_k_drafts`` tunes speculative decoding on models whose capabilities advertise ``supports_spec_decode`` (-1 = family default, 0 = disabled, >0 = draft length; silently ignored elsewhere). + ``hotwords`` is an optional comma-joined keyword-biasing hint honored + by models whose ``supports("hotwords")`` is true (e.g. moss, granite + AR); silently ignored elsewhere. None/empty means no hint. On ``Aborted`` (via :meth:`cancel`) and ``OutputTruncated`` the partial transcript is preserved and attached to the exception as @@ -1074,7 +1081,8 @@ 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, + hotwords) ext = self._resolve_family(family, "run") if family is not None else None if ext is not None: params.family = ctypes.cast( @@ -1096,6 +1104,7 @@ def run_batch(self, pcms: Sequence[PCMLike], *, task: Task = "transcribe", diarize: Diarize = "default", keep_special_tags: bool = False, spec_k_drafts: int = -1, + hotwords: str | None = None, family: FamilyExtension | None = None, return_exceptions: bool = False) -> list[Result | TranscribeError]: """Transcribe several utterances in one dispatch — one Result each. @@ -1130,7 +1139,8 @@ 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, + hotwords) ext = self._resolve_family(family, "run") if family is not None else None if ext is not None: params.family = ctypes.cast( @@ -1193,8 +1203,8 @@ def stream(self, *, task: Task = "transcribe", language: str | None = None, session is single-threaded and runs at most one stream at a time. Use the Stream as a context manager so it is reset when you are done.""" self._cancel.clear() - # spec_k_drafts is an offline-decode knob; streaming always uses the - # family default (-1). + # spec_k_drafts and hotwords are offline-decode knobs; streaming always + # uses the family defaults (-1 / no hint). run_params = _build_run_params(task, language, target_language, timestamps, keep_special_tags, -1, diarize) sp = _StreamParams() @@ -1441,6 +1451,7 @@ def transcribe( diarize: Diarize = "default", keep_special_tags: bool = False, spec_k_drafts: int = -1, + hotwords: str | None = None, family: FamilyExtension | None = None, ) -> Result: """Transcribe *pcm* in one call and return a materialized Result. @@ -1450,13 +1461,14 @@ def transcribe( many clips keep a Model and call ``model.session().run(...)`` yourself; this helper is for the one-shot case. ``backend`` / ``gpu_device`` apply only when *model* is a path — they are ignored when an already-loaded Model is passed. - ``family`` / ``spec_k_drafts`` pass through to :meth:`Session.run`. + ``family`` / ``spec_k_drafts`` / ``hotwords`` pass through to + :meth:`Session.run`. """ 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, keep_special_tags=keep_special_tags, - spec_k_drafts=spec_k_drafts, family=family) + spec_k_drafts=spec_k_drafts, hotwords=hotwords, family=family) if isinstance(model, Model): with model.session(**session_opts) as session: diff --git a/bindings/python/src/transcribe_cpp/_generated.py b/bindings/python/src/transcribe_cpp/_generated.py index 9c133653..a73dd27a 100644 --- a/bindings/python/src/transcribe_cpp/_generated.py +++ b/bindings/python/src/transcribe_cpp/_generated.py @@ -13,7 +13,7 @@ # Stable digest of the ABI surface below (structs, enums, macros, layout, # prototypes). A native provider package echoes this back so the API # package can reject an ABI-mismatched provider before dlopen. -PUBLIC_HEADER_HASH = "fb2e64791dcbb70a" +PUBLIC_HEADER_HASH = "4faee4159facd940" # === enum constants === TRANSCRIBE_OK = 0 @@ -94,6 +94,7 @@ TRANSCRIBE_FEATURE_PNC = 4 TRANSCRIBE_FEATURE_ITN = 5 TRANSCRIBE_FEATURE_DIARIZATION = 6 +TRANSCRIBE_FEATURE_HOTWORDS = 7 TRANSCRIBE_STREAM_IDLE = 0 TRANSCRIBE_STREAM_ACTIVE = 1 TRANSCRIBE_STREAM_FINISHED = 2 @@ -166,7 +167,7 @@ class transcribe_whisper_chunk_trace(_c.Structure): transcribe_backend_device._fields_ = [("struct_size", _c.c_uint64), ("name", _c.c_char_p), ("description", _c.c_char_p), ("kind", _c.c_char_p), ("device_id", _c.c_char_p), ("memory_total", _c.c_uint64), ("memory_free", _c.c_uint64), ("device_type", _c.c_int)] transcribe_model_load_params._fields_ = [("struct_size", _c.c_uint64), ("backend", _c.c_int), ("gpu_device", _c.c_int)] transcribe_session_params._fields_ = [("struct_size", _c.c_uint64), ("n_threads", _c.c_int), ("kv_type", _c.c_int), ("n_ctx", _c.c_int32)] -transcribe_run_params._fields_ = [("struct_size", _c.c_uint64), ("task", _c.c_int), ("timestamps", _c.c_int), ("pnc", _c.c_int), ("itn", _c.c_int), ("diarize", _c.c_int), ("language", _c.c_char_p), ("target_language", _c.c_char_p), ("keep_special_tags", _c.c_bool), ("family", _c.POINTER(transcribe_ext)), ("spec_k_drafts", _c.c_int32)] +transcribe_run_params._fields_ = [("struct_size", _c.c_uint64), ("task", _c.c_int), ("timestamps", _c.c_int), ("pnc", _c.c_int), ("itn", _c.c_int), ("diarize", _c.c_int), ("language", _c.c_char_p), ("target_language", _c.c_char_p), ("keep_special_tags", _c.c_bool), ("family", _c.POINTER(transcribe_ext)), ("spec_k_drafts", _c.c_int32), ("hotwords", _c.c_char_p)] transcribe_capabilities._fields_ = [("struct_size", _c.c_uint64), ("native_sample_rate", _c.c_int32), ("n_languages", _c.c_int), ("languages", _c.POINTER(_c.c_char_p)), ("max_timestamp_kind", _c.c_int), ("supports_language_detect", _c.c_bool), ("supports_translate", _c.c_bool), ("supports_streaming", _c.c_bool), ("supports_spec_decode", _c.c_bool), ("max_audio_ms", _c.c_int64), ("n_translate_target_languages", _c.c_int), ("translate_target_languages", _c.POINTER(_c.c_char_p))] transcribe_session_limits._fields_ = [("struct_size", _c.c_uint64), ("effective_n_ctx", _c.c_int32), ("effective_max_audio_ms", _c.c_int64), ("max_kv_bytes", _c.c_int64)] transcribe_stream_params._fields_ = [("struct_size", _c.c_uint64), ("family", _c.POINTER(transcribe_ext)), ("commit_policy", _c.c_int), ("stable_prefix_agreement_n", _c.c_uint32)] @@ -211,7 +212,7 @@ class transcribe_whisper_chunk_trace(_c.Structure): 'transcribe_backend_device': {'size': 64, 'align': 8, 'offsets': {'struct_size': 0, 'name': 8, 'description': 16, 'kind': 24, 'device_id': 32, 'memory_total': 40, 'memory_free': 48, 'device_type': 56}}, 'transcribe_model_load_params': {'size': 16, 'align': 8, 'offsets': {'struct_size': 0, 'backend': 8, 'gpu_device': 12}}, 'transcribe_session_params': {'size': 24, 'align': 8, 'offsets': {'struct_size': 0, 'n_threads': 8, 'kv_type': 12, 'n_ctx': 16}}, - 'transcribe_run_params': {'size': 72, 'align': 8, 'offsets': {'struct_size': 0, 'task': 8, 'timestamps': 12, 'pnc': 16, 'itn': 20, 'diarize': 24, 'language': 32, 'target_language': 40, 'keep_special_tags': 48, 'family': 56, 'spec_k_drafts': 64}}, + 'transcribe_run_params': {'size': 80, 'align': 8, 'offsets': {'struct_size': 0, 'task': 8, 'timestamps': 12, 'pnc': 16, 'itn': 20, 'diarize': 24, 'language': 32, 'target_language': 40, 'keep_special_tags': 48, 'family': 56, 'spec_k_drafts': 64, 'hotwords': 72}}, 'transcribe_capabilities': {'size': 56, 'align': 8, 'offsets': {'struct_size': 0, 'native_sample_rate': 8, 'n_languages': 12, 'languages': 16, 'max_timestamp_kind': 24, 'supports_language_detect': 28, 'supports_translate': 29, 'supports_streaming': 30, 'supports_spec_decode': 31, 'max_audio_ms': 32, 'n_translate_target_languages': 40, 'translate_target_languages': 48}}, 'transcribe_session_limits': {'size': 32, 'align': 8, 'offsets': {'struct_size': 0, 'effective_n_ctx': 8, 'effective_max_audio_ms': 16, 'max_kv_bytes': 24}}, 'transcribe_stream_params': {'size': 24, 'align': 8, 'offsets': {'struct_size': 0, 'family': 8, 'commit_policy': 16, 'stable_prefix_agreement_n': 20}}, diff --git a/bindings/python/tests/test_family_ext.py b/bindings/python/tests/test_family_ext.py index 3b0a433e..4c9692f3 100644 --- a/bindings/python/tests/test_family_ext.py +++ b/bindings/python/tests/test_family_ext.py @@ -144,7 +144,7 @@ def test_run_batch_accepts_family(model_path, audio_pcm): def test_supports_probe_all_features(model_path): with t.Model(model_path) as model: for feature in ("initial_prompt", "temperature_fallback", "long_form", - "cancellation", "pnc", "itn", "diarization"): + "cancellation", "pnc", "itn", "diarization", "hotwords"): assert model.supports(feature) in (True, False) with pytest.raises(t.InvalidArgument, match="unknown feature"): model.supports("levitation") diff --git a/bindings/rust/sys/src/transcribe_sys.rs b/bindings/rust/sys/src/transcribe_sys.rs index bb3b5a39..531e1a8f 100644 --- a/bindings/rust/sys/src/transcribe_sys.rs +++ b/bindings/rust/sys/src/transcribe_sys.rs @@ -1,11 +1,11 @@ // @generated by `cargo xtask bindgen` from include/transcribe/extensions.h // DO NOT EDIT BY HAND. Regenerate: `cargo xtask bindgen`. -// Pinned to include/transcribe.abihash = fb2e64791dcbb70a +// Pinned to include/transcribe.abihash = 4faee4159facd940 /// The public-ABI digest these bindings were generated against /// (sha256/16 over the normalized FFI surface). The load-time version /// gate and the CI drift check both anchor on this value. -pub const PUBLIC_HEADER_HASH: &str = "fb2e64791dcbb70a"; +pub const PUBLIC_HEADER_HASH: &str = "4faee4159facd940"; /* automatically generated by rust-bindgen 0.72.1 */ @@ -340,10 +340,11 @@ pub struct transcribe_run_params { pub keep_special_tags: bool, pub family: *const transcribe_ext, pub spec_k_drafts: i32, + pub hotwords: *const ::std::os::raw::c_char, } #[allow(clippy::unnecessary_operation, clippy::identity_op)] const _: () = { - ["Size of transcribe_run_params"][::std::mem::size_of::() - 72usize]; + ["Size of transcribe_run_params"][::std::mem::size_of::() - 80usize]; ["Alignment of transcribe_run_params"] [::std::mem::align_of::() - 8usize]; ["Offset of field: transcribe_run_params::struct_size"] @@ -368,6 +369,8 @@ const _: () = { [::std::mem::offset_of!(transcribe_run_params, family) - 56usize]; ["Offset of field: transcribe_run_params::spec_k_drafts"] [::std::mem::offset_of!(transcribe_run_params, spec_k_drafts) - 64usize]; + ["Offset of field: transcribe_run_params::hotwords"] + [::std::mem::offset_of!(transcribe_run_params, hotwords) - 72usize]; }; unsafe extern "C" { pub fn transcribe_run_params_init(params: *mut transcribe_run_params); @@ -435,6 +438,7 @@ impl transcribe_feature { pub const TRANSCRIBE_FEATURE_PNC: transcribe_feature = transcribe_feature(4); pub const TRANSCRIBE_FEATURE_ITN: transcribe_feature = transcribe_feature(5); pub const TRANSCRIBE_FEATURE_DIARIZATION: transcribe_feature = transcribe_feature(6); + pub const TRANSCRIBE_FEATURE_HOTWORDS: transcribe_feature = transcribe_feature(7); } #[repr(transparent)] #[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] diff --git a/bindings/rust/transcribe-cpp/src/session.rs b/bindings/rust/transcribe-cpp/src/session.rs index 220f2b63..1619de31 100644 --- a/bindings/rust/transcribe-cpp/src/session.rs +++ b/bindings/rust/transcribe-cpp/src/session.rs @@ -41,6 +41,9 @@ pub struct RunOptions { pub keep_special_tags: bool, /// Speculative-decode draft length. `-1` = family default, `0` = disabled. pub spec_k_drafts: i32, + /// Comma-joined keyword-biasing hint. `None` = no hint; ignored by + /// families without hotword support. + pub hotwords: Option, /// Optional family-specific run extension (e.g. whisper decode knobs). pub family: Option, } @@ -57,6 +60,7 @@ impl Default for RunOptions { target_language: None, keep_special_tags: false, spec_k_drafts: -1, + hotwords: None, family: None, } } @@ -148,7 +152,7 @@ impl Session { /// On an aborted or truncated decode the partial transcript is preserved /// on the returned [`Error::Aborted`] / [`Error::OutputTruncated`]. pub fn run(&mut self, pcm: &[f32], options: &RunOptions) -> Result { - let (params, _lang, _target, _family) = build_run_params(options)?; + let (params, _lang, _target, _hotwords, _family) = build_run_params(options)?; let n = clamp_len(pcm.len())?; // The compute path is serialized per model; hold the lock for the native @@ -191,7 +195,7 @@ impl Session { pcms: &[&[f32]], options: &RunOptions, ) -> Result>> { - let (params, _lang, _target, _family) = build_run_params(options)?; + let (params, _lang, _target, _hotwords, _family) = build_run_params(options)?; let ptrs: Vec<*const f32> = pcms.iter().map(|p| p.as_ptr()).collect(); let lens: Vec = pcms .iter() @@ -267,7 +271,7 @@ impl Session { /// Dropping the returned `Stream` abandons it and returns the session to /// idle. pub fn stream(&mut self, run: &RunOptions, stream: &StreamOptions) -> Result> { - let (run_params, _lang, _target, _family) = build_run_params(run)?; + let (run_params, _lang, _target, _hotwords, _family) = build_run_params(run)?; let (stream_params, _stream_family) = build_stream_params(stream); { // Claim the model's compute lease for the whole stream lifetime: a @@ -417,11 +421,13 @@ impl Session { } /// Everything that must outlive a `transcribe_run` call: the params struct -/// plus the heap buffers its pointers borrow (language strings, family ext). +/// plus the heap buffers its pointers borrow (language strings, hotwords, +/// family ext). type RunParamsBundle = ( sys::transcribe_run_params, Option, Option, + Option, Option, ); @@ -442,8 +448,10 @@ fn build_run_params(o: &RunOptions) -> Result { let lang = o.language.as_deref().map(CString::new).transpose()?; let target = o.target_language.as_deref().map(CString::new).transpose()?; + let hotwords = o.hotwords.as_deref().map(CString::new).transpose()?; params.language = lang.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()); params.target_language = target.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()); + params.hotwords = hotwords.as_ref().map_or(std::ptr::null(), |c| c.as_ptr()); let family = o .family @@ -452,7 +460,7 @@ fn build_run_params(o: &RunOptions) -> Result { .transpose()?; params.family = family.as_ref().map_or(std::ptr::null(), |f| f.ext_ptr()); - Ok((params, lang, target, family)) + Ok((params, lang, target, hotwords, family)) } /// PCM/utterance lengths cross the ABI as `int`; reject anything that overflows. diff --git a/bindings/rust/transcribe-cpp/src/types.rs b/bindings/rust/transcribe-cpp/src/types.rs index d4cd37f4..5fef7f75 100644 --- a/bindings/rust/transcribe-cpp/src/types.rs +++ b/bindings/rust/transcribe-cpp/src/types.rs @@ -208,6 +208,8 @@ pub enum Feature { Itn, /// Produces structured speaker attribution. Diarization, + /// Honors the keyword-biasing hotwords hint. + Hotwords, } impl Feature { @@ -221,6 +223,7 @@ impl Feature { Feature::Pnc => F::TRANSCRIBE_FEATURE_PNC, Feature::Itn => F::TRANSCRIBE_FEATURE_ITN, Feature::Diarization => F::TRANSCRIBE_FEATURE_DIARIZATION, + Feature::Hotwords => F::TRANSCRIBE_FEATURE_HOTWORDS, } } } diff --git a/bindings/swift/Sources/TranscribeCpp/ABIHash.swift b/bindings/swift/Sources/TranscribeCpp/ABIHash.swift index 500fbf03..e06677fe 100644 --- a/bindings/swift/Sources/TranscribeCpp/ABIHash.swift +++ b/bindings/swift/Sources/TranscribeCpp/ABIHash.swift @@ -13,7 +13,7 @@ import CTranscribe extension Transcribe { /// sha256/16 of the normalized public FFI surface, pinned to the value in /// include/transcribe.abihash at the time this binding was last reviewed. - public static let pinnedHeaderHash = "fb2e64791dcbb70a" + public static let pinnedHeaderHash = "4faee4159facd940" /// The public-ABI digest this binding was reviewed against (16 hex chars). public static func headerHash() -> String { pinnedHeaderHash } diff --git a/bindings/swift/Sources/TranscribeCpp/Options.swift b/bindings/swift/Sources/TranscribeCpp/Options.swift index 36a957d6..e00ab8be 100644 --- a/bindings/swift/Sources/TranscribeCpp/Options.swift +++ b/bindings/swift/Sources/TranscribeCpp/Options.swift @@ -81,7 +81,7 @@ public enum Diarize: Sendable { } public enum Feature: Sendable { - case initialPrompt, temperatureFallback, longForm, cancellation, pnc, itn, diarization + case initialPrompt, temperatureFallback, longForm, cancellation, pnc, itn, diarization, hotwords var cValue: transcribe_feature { switch self { case .initialPrompt: return TRANSCRIBE_FEATURE_INITIAL_PROMPT @@ -91,6 +91,7 @@ public enum Feature: Sendable { case .pnc: return TRANSCRIBE_FEATURE_PNC case .itn: return TRANSCRIBE_FEATURE_ITN case .diarization: return TRANSCRIBE_FEATURE_DIARIZATION + case .hotwords: return TRANSCRIBE_FEATURE_HOTWORDS } } } @@ -134,6 +135,8 @@ public struct RunOptions: Sendable { public var keepSpecialTags: Bool /// Speculative-decode draft length: -1 = family default, 0 = disabled. public var specKDrafts: Int32 + /// Comma-joined keyword-biasing hint; ignored by families without support. + public var hotwords: String? /// Family-specific run extension (whisper run options); M3. public var family: RunExtension? @@ -150,6 +153,7 @@ public struct RunOptions: Sendable { targetLanguage: String? = nil, keepSpecialTags: Bool = false, specKDrafts: Int32 = -1, + hotwords: String? = nil, family: RunExtension? = nil ) { self.task = task @@ -161,12 +165,14 @@ public struct RunOptions: Sendable { self.targetLanguage = targetLanguage self.keepSpecialTags = keepSpecialTags self.specKDrafts = specKDrafts + self.hotwords = hotwords self.family = family } /// Materialize a `transcribe_run_params` and run `body` with a pointer to - /// it. The `language` / `target_language` C strings are kept alive for the - /// duration of `body` (the C side copies them before returning). + /// it. The `language` / `target_language` / `hotwords` C strings are kept + /// alive for the duration of `body` (the C side copies them before + /// returning). func withCParams(_ body: (UnsafePointer) throws -> R) rethrows -> R { var params = transcribe_run_params() transcribe_run_params_init(¶ms) @@ -181,9 +187,12 @@ public struct RunOptions: Sendable { params.language = lang return try withOptionalCString(targetLanguage) { tgt in params.target_language = tgt - return try withRunExtension(family) { ext in - params.family = ext - return try withUnsafePointer(to: ¶ms) { try body($0) } + return try withOptionalCString(hotwords) { hw in + params.hotwords = hw + return try withRunExtension(family) { ext in + params.family = ext + return try withUnsafePointer(to: ¶ms) { try body($0) } + } } } } diff --git a/bindings/typescript/src/_generated.ts b/bindings/typescript/src/_generated.ts index 6d316b12..bc20e8ef 100644 --- a/bindings/typescript/src/_generated.ts +++ b/bindings/typescript/src/_generated.ts @@ -11,7 +11,7 @@ // Stable digest of the ABI surface (structs, enums, macros, layout, // prototypes), computed by the Python oracle and pinned here so a header // ABI change turns this binding's drift check red for conscious review. -export const PUBLIC_HEADER_HASH = "fb2e64791dcbb70a"; +export const PUBLIC_HEADER_HASH = "4faee4159facd940"; // === enum constants === export const TRANSCRIBE_OK = 0; @@ -92,6 +92,7 @@ export const TRANSCRIBE_FEATURE_CANCELLATION = 3; export const TRANSCRIBE_FEATURE_PNC = 4; export const TRANSCRIBE_FEATURE_ITN = 5; export const TRANSCRIBE_FEATURE_DIARIZATION = 6; +export const TRANSCRIBE_FEATURE_HOTWORDS = 7; export const TRANSCRIBE_STREAM_IDLE = 0; export const TRANSCRIBE_STREAM_ACTIVE = 1; export const TRANSCRIBE_STREAM_FINISHED = 2; @@ -120,7 +121,7 @@ export const STRUCT_LAYOUT: Record = { 'transcribe_backend_device': { size: 64, align: 8, offsets: {'struct_size': 0, 'name': 8, 'description': 16, 'kind': 24, 'device_id': 32, 'memory_total': 40, 'memory_free': 48, 'device_type': 56} }, 'transcribe_model_load_params': { size: 16, align: 8, offsets: {'struct_size': 0, 'backend': 8, 'gpu_device': 12} }, 'transcribe_session_params': { size: 24, align: 8, offsets: {'struct_size': 0, 'n_threads': 8, 'kv_type': 12, 'n_ctx': 16} }, - 'transcribe_run_params': { size: 72, align: 8, offsets: {'struct_size': 0, 'task': 8, 'timestamps': 12, 'pnc': 16, 'itn': 20, 'diarize': 24, 'language': 32, 'target_language': 40, 'keep_special_tags': 48, 'family': 56, 'spec_k_drafts': 64} }, + 'transcribe_run_params': { size: 80, align: 8, offsets: {'struct_size': 0, 'task': 8, 'timestamps': 12, 'pnc': 16, 'itn': 20, 'diarize': 24, 'language': 32, 'target_language': 40, 'keep_special_tags': 48, 'family': 56, 'spec_k_drafts': 64, 'hotwords': 72} }, 'transcribe_capabilities': { size: 56, align: 8, offsets: {'struct_size': 0, 'native_sample_rate': 8, 'n_languages': 12, 'languages': 16, 'max_timestamp_kind': 24, 'supports_language_detect': 28, 'supports_translate': 29, 'supports_streaming': 30, 'supports_spec_decode': 31, 'max_audio_ms': 32, 'n_translate_target_languages': 40, 'translate_target_languages': 48} }, 'transcribe_session_limits': { size: 32, align: 8, offsets: {'struct_size': 0, 'effective_n_ctx': 8, 'effective_max_audio_ms': 16, 'max_kv_bytes': 24} }, 'transcribe_stream_params': { size: 24, align: 8, offsets: {'struct_size': 0, 'family': 8, 'commit_policy': 16, 'stable_prefix_agreement_n': 20} }, @@ -165,7 +166,7 @@ export function defineTypes(koffi: any): Record { T['transcribe_backend_device'] = koffi.struct({ struct_size: 'uint64_t', name: 'char *', description: 'char *', kind: 'char *', device_id: 'char *', memory_total: 'uint64_t', memory_free: 'uint64_t', device_type: 'int' }); T['transcribe_model_load_params'] = koffi.struct({ struct_size: 'uint64_t', backend: 'int', gpu_device: 'int' }); T['transcribe_session_params'] = koffi.struct({ struct_size: 'uint64_t', n_threads: 'int', kv_type: 'int', n_ctx: 'int32_t' }); - T['transcribe_run_params'] = koffi.struct({ struct_size: 'uint64_t', task: 'int', timestamps: 'int', pnc: 'int', itn: 'int', diarize: 'int', language: 'char *', target_language: 'char *', keep_special_tags: 'bool', family: 'void *', spec_k_drafts: 'int32_t' }); + T['transcribe_run_params'] = koffi.struct({ struct_size: 'uint64_t', task: 'int', timestamps: 'int', pnc: 'int', itn: 'int', diarize: 'int', language: 'char *', target_language: 'char *', keep_special_tags: 'bool', family: 'void *', spec_k_drafts: 'int32_t', hotwords: 'char *' }); T['transcribe_capabilities'] = koffi.struct({ struct_size: 'uint64_t', native_sample_rate: 'int32_t', n_languages: 'int', languages: 'void *', max_timestamp_kind: 'int', supports_language_detect: 'bool', supports_translate: 'bool', supports_streaming: 'bool', supports_spec_decode: 'bool', max_audio_ms: 'int64_t', n_translate_target_languages: 'int', translate_target_languages: 'void *' }); T['transcribe_session_limits'] = koffi.struct({ struct_size: 'uint64_t', effective_n_ctx: 'int32_t', effective_max_audio_ms: 'int64_t', max_kv_bytes: 'int64_t' }); T['transcribe_stream_params'] = koffi.struct({ struct_size: 'uint64_t', family: 'void *', commit_policy: 'int', stable_prefix_agreement_n: 'uint32_t' }); diff --git a/bindings/typescript/src/index.ts b/bindings/typescript/src/index.ts index 970ba670..add62258 100644 --- a/bindings/typescript/src/index.ts +++ b/bindings/typescript/src/index.ts @@ -101,6 +101,7 @@ const FEATURES: Record = { pnc: g.TRANSCRIBE_FEATURE_PNC, itn: g.TRANSCRIBE_FEATURE_ITN, diarization: g.TRANSCRIBE_FEATURE_DIARIZATION, + hotwords: g.TRANSCRIBE_FEATURE_HOTWORDS, }; // ---- helpers --------------------------------------------------------------- @@ -824,6 +825,7 @@ export class Session { if (opts.keepSpecialTags !== undefined) p.keep_special_tags = opts.keepSpecialTags; if (opts.specKDrafts !== undefined) p.spec_k_drafts = opts.specKDrafts; + if (opts.hotwords !== undefined) p.hotwords = opts.hotwords; if (opts.family) p.family = buildFamily(n, this.#model.handle, opts.family, "run"); return p; diff --git a/bindings/typescript/src/types.ts b/bindings/typescript/src/types.ts index 9c087d72..6a417f15 100644 --- a/bindings/typescript/src/types.ts +++ b/bindings/typescript/src/types.ts @@ -14,7 +14,8 @@ export type Feature = | "cancellation" | "pnc" | "itn" - | "diarization"; + | "diarization" + | "hotwords"; /** Mono float32 PCM at the model's native sample rate (16 kHz for v1). */ export type PcmLike = Float32Array | number[] | ArrayBuffer | Buffer; @@ -157,6 +158,8 @@ export interface TranscribeOptions { keepSpecialTags?: boolean; /** Speculative-decode draft count; -1 = family default. */ specKDrafts?: number; + /** Comma-joined keyword-biasing hint; ignored by families without support. */ + hotwords?: string; /** Cancel the run cooperatively. */ signal?: AbortSignal; /** A run-slot family extension (e.g. whisper). */ diff --git a/docs/models/granite-4.0-1b-speech.md b/docs/models/granite-4.0-1b-speech.md index 7f415dbc..38aa1d88 100644 --- a/docs/models/granite-4.0-1b-speech.md +++ b/docs/models/granite-4.0-1b-speech.md @@ -132,6 +132,7 @@ Linux 6.18 (Fedora 43), transcribe.cpp `dbe5814`. | Translate (en↔ASR, en→it/zh) | Yes (`--translate --target-language `) | | Word-level timestamps | No (use the `-plus` variant) | | Speaker diarization | No (upstream supports via prompt; not exposed in v1 of transcribe.cpp) | +| Keyword biasing (hotwords) | Yes (`--hotwords "kw1, kw2"`) | ## Numerical Validation diff --git a/docs/models/granite-speech-4.1-2b-plus.md b/docs/models/granite-speech-4.1-2b-plus.md index a3cc164a..a7667353 100644 --- a/docs/models/granite-speech-4.1-2b-plus.md +++ b/docs/models/granite-speech-4.1-2b-plus.md @@ -155,6 +155,7 @@ Linux 6.18 (Fedora 43), transcribe.cpp `dbe5814`. | Translate | No (ASR-only variant; use the base [granite-speech-4.1-2b](granite-speech-4.1-2b.md) for translation) | | Word-level timestamps | Yes (`--timestamps word`, structured per-word t0/t1 parsed from the model's `[T:N]` markers) | | Speaker diarization | Yes (`--diarize`; structured per-turn `speaker_id`, no timing) | +| Keyword biasing (hotwords) | Yes (`--hotwords "kw1, kw2"`) | Speaker attribution is off by default. `--diarize` selects Granite's distinct speaker-attribution prompt and parses `[Speaker N]:` markers into segment and @@ -163,6 +164,29 @@ speaker-turn rows. This task carries no timestamps: `--timestamps none` and rejected instead of silently downgraded. The speaker-attribution and word- timestamp prompts cannot be combined. +### Hotword hints (keyword biasing) + +Pass `--hotwords "kubernetes, gRPC, OTEL"` to bias transcription toward domain +terms. The runtime appends IBM's trained ` Keywords: ` clause to the task +instruction, matching the model card's keyword-biasing surface form. On the +plus checkpoint IBM reports keyword F1 gains (e.g. 68.8 -> 81.5 on Earnings22), +with the noisiest, most jargon-heavy audio benefiting most. Notes: + +- The surface form is load-bearing: the model **silently** ignores a malformed + or paraphrased prompt and falls back to plain transcription, so the library + appends the exact trained ` Keywords:` clause and passes your list through + verbatim. Provide it already joined with `, `. +- The list may include terms that never appear in a given clip, so a single + standing glossary can be reused across recordings. Cap at a few dozen terms + and measure. +- **-plus emits no punctuation/capitalization**, so keywords come back + lowercased and unpunctuated. +- Keyword biasing is documented by IBM **only for ASR mode**. Combining it with + `--diarize` or `--timestamps word` is unverified upstream; the library still + applies it but emits a one-time warning, because an off-distribution prompt + may weaken `[Speaker N]:`/timestamp emission. Verify tag survival on your own + audio before relying on the combination. + ## Numerical Validation Tensor-level parity with the transformers reference on `samples/jfk.wav`. diff --git a/docs/models/granite-speech-4.1-2b.md b/docs/models/granite-speech-4.1-2b.md index d0fd3b9c..a3df97e4 100644 --- a/docs/models/granite-speech-4.1-2b.md +++ b/docs/models/granite-speech-4.1-2b.md @@ -132,7 +132,21 @@ Linux 6.18 (Fedora 43), transcribe.cpp `dbe5814`. | Transcribe (fr/de/es/pt/ja) | Yes | | Translate (en↔ASR, en→it/zh) | Yes (`--translate --target-language `) | | Word-level timestamps | No (use the `-plus` variant) | -| Keyword biasing | No (upstream supports via prompt; not exposed in v1 of transcribe.cpp) | +| Keyword biasing (hotwords) | Yes (`--hotwords "kw1, kw2"`) | + +Pass `--hotwords "term1, term2"` to bias decoding toward domain terms. Keyword +biasing is a **distinct trained task**, not a suffix on the normal ASR prompt: +IBM's model card gives the exact prompt as +`transcribe the speech to text. Keywords: , , …` for ASR and +`translate the speech to . Keywords: , , …` for translation, so +the runtime swaps to that stem and passes your `, `-joined list through verbatim. +The surface form is load-bearing — a paraphrased prompt is silently ignored and +the model falls back to plain transcription — which is why the exact trained stem +is used rather than appending to the punctuated-ASR prompt. Cap the list at a few +dozen terms and measure. Because keyword biasing is its own task (the raw +`transcribe the speech to text` stem, separate from the punctuated-ASR task), +expect keyword-biased output to be raw/unpunctuated rather than the punctuated +transcript the default prompt produces. ## Numerical Validation diff --git a/docs/models/granite-speech.md b/docs/models/granite-speech.md index 24dcf9e8..4131b0a2 100644 --- a/docs/models/granite-speech.md +++ b/docs/models/granite-speech.md @@ -92,6 +92,26 @@ All variants: - **Transcription** of 16 kHz mono WAV input across the variant's supported languages. +Keyword biasing / hotwords (AR variants — `granite-4.0-1b-speech`, +`granite-speech-4.1-2b`, `granite-speech-4.1-2b-plus`): +- **Hotword hints** via `--hotwords "kw1, kw2"` (library: + `transcribe_run_params::hotwords`) bias decoding toward domain terms. Keyword + biasing is a **distinct trained prompt whose exact form varies by variant**, + not one clause bolted onto every task — an off-distribution prompt is silently + ignored (the model falls back to plain transcription), so the runtime selects + the trained stem per variant and passes the caller's `, `-joined list through + verbatim: + - `granite-speech-4.1-2b`: `transcribe the speech to text. Keywords: ` + for ASR and `translate the speech to . Keywords: ` for + translation (per the model card's preferred-prompt table). + - `granite-speech-4.1-2b-plus`: appends ` Keywords: ` to its plain-ASR + prompt (`can you transcribe the speech into a written format?`), as the card + documents; `-plus` is ASR-only, so there is no translation form. + - `granite-4.0-1b-speech`: no keyword-biasing prompt is published, so the + runtime uses the same plain-ASR-append form as best effort. + IBM documents keyword biasing for ASR (and, on the 2b card, AST); combining it + with speaker attribution or word timestamps is unverified upstream and warns. + Translation (`granite-4.0-1b-speech`, `granite-speech-4.1-2b`): - **Translation** between English and each ASR language in either direction (en ↔ fr, en ↔ de, en ↔ es, en ↔ pt, en ↔ ja), plus English-to-Italian @@ -110,6 +130,5 @@ Plus only (`granite-speech-4.1-2b-plus`): NAR only (`granite-speech-4.1-2b-nar`): - **Single-pass non-autoregressive decode** — fastest of the four. -What's not exposed by the v1 transcribe.cpp runtime: keyword/hotword biasing -(advertised on AR variants), real-time streaming, VAD. See the per-variant -docs for status. +What's not exposed by the v1 transcribe.cpp runtime: real-time streaming, VAD. +See the per-variant docs for status. diff --git a/docs/models/moss-transcribe-diarize.md b/docs/models/moss-transcribe-diarize.md index e1d920f1..96edabd1 100644 --- a/docs/models/moss-transcribe-diarize.md +++ b/docs/models/moss-transcribe-diarize.md @@ -91,8 +91,17 @@ CLI flags: `speaker_id` and speaker-turn rows; `--no-diarize` is the explicit off form. - Timestamp selection is independent: `--timestamps segment` or `auto` keeps parsed turn timing; `--timestamps none` returns attribution with zero times. +- `--hotwords "kw1, kw2, kw3"` biases decoding toward domain terms (see below). - `full_text` is always clean marker-free text after a successful parse. +### Hotword hints + +Pass `--hotwords "kw1, kw2, kw3"` with a comma-separated list to bias decoding +toward names, jargon, or acronyms the model otherwise mis-transcribes. Speaker +attribution and timestamps are unaffected. An empty or omitted value leaves the +default behavior unchanged. Keep the list to a few dozen terms and measure — +very long lists dilute the effect. + ## Performance Cells are wall-clock latency (mean over 3 iterations after 1 warmup), diff --git a/examples/cli/main.cpp b/examples/cli/main.cpp index df820403..3dedc84a 100644 --- a/examples/cli/main.cpp +++ b/examples/cli/main.cpp @@ -237,6 +237,11 @@ struct cli_args { bool diarize = true; bool diarize_set = false; + // Hotword/keyword biasing hint (moss / granite AR variants). Empty = none. + // --hotwords "w1, w2, w3" sets rp.hotwords; ignored by families that + // do not advertise TRANSCRIBE_FEATURE_HOTWORDS. + std::string hotwords; // --hotwords "w1, w2, w3" + // Streaming demo: when > 0, the single-file path feeds the WAV // through transcribe_stream_begin/feed/finalize in fixed-size // ms-aligned chunks instead of one transcribe_run call. Requires @@ -307,6 +312,8 @@ void print_usage(const char * argv0) { " --diarize (moss/granite-plus) speaker attribution: segments carry\n" " speaker ids; granite-plus requests its speaker task\n" " --no-diarize disable speaker attribution (the library default)\n" + " --hotwords LIST (moss/granite) bias decoding toward comma-separated\n" + " keywords, e.g. --hotwords \"kubernetes, gRPC, Mori\"\n" " --raw-tokens keep <|...|> control tokens in output text\n" " --stream-chunk-ms N single-file: drive the streaming API by feeding\n" " N-ms PCM slices; requires model to advertise\n" @@ -576,6 +583,12 @@ bool parse_args(int argc, char ** argv, cli_args & out) { } else if (a == "--no-diarize") { out.diarize = false; out.diarize_set = true; + } else if (a == "--hotwords") { + const char * v = take_value(a.c_str()); + if (!v) { + return false; + } + out.hotwords = v; } else if (a == "--raw-tokens") { out.keep_special_tags = true; } else if (a == "--stream-chunk-ms") { @@ -796,6 +809,9 @@ int main(int argc, char ** argv) { if (args.diarize_set) { rp.diarize = args.diarize ? TRANSCRIBE_DIARIZE_MODE_ON : TRANSCRIBE_DIARIZE_MODE_OFF; } + if (!args.hotwords.empty()) { + rp.hotwords = args.hotwords.c_str(); + } // Whisper run extension. Allocated outside rp's scope so its // bytes outlive the per-file loop below; the library copies @@ -1208,6 +1224,9 @@ int main(int argc, char ** argv) { if (args.diarize_set) { rp.diarize = args.diarize ? TRANSCRIBE_DIARIZE_MODE_ON : TRANSCRIBE_DIARIZE_MODE_OFF; } + if (!args.hotwords.empty()) { + rp.hotwords = args.hotwords.c_str(); + } struct transcribe_whisper_run_ext wx; transcribe_whisper_run_ext_init(&wx); diff --git a/include/transcribe.abihash b/include/transcribe.abihash index 8513799e..ad2d051d 100644 --- a/include/transcribe.abihash +++ b/include/transcribe.abihash @@ -1 +1 @@ -fb2e64791dcbb70a +4faee4159facd940 diff --git a/include/transcribe.h b/include/transcribe.h index 7194efbb..224a8df4 100644 --- a/include/transcribe.h +++ b/include/transcribe.h @@ -1063,6 +1063,22 @@ TRANSCRIBE_API void transcribe_session_params_init(struct transcribe_session_par * ext` as field 0. Use transcribe_model_accepts_ext_kind * to probe whether the loaded model accepts a given kind * before pointing `family` at it. + * + * hotwords: optional keyword/hotword biasing hint, or NULL. A + * NUL-terminated UTF-8 string of caller-joined terms (e.g. + * "kubernetes, gRPC, OTEL"); the library appends it to the + * model's prompt so decoding is biased toward those terms. + * NULL or "" means no hint (behavior is byte-identical to + * omitting the field). Honored only by families whose model + * declares transcribe_model_supports(model, + * TRANSCRIBE_FEATURE_HOTWORDS) == true (currently moss and + * the granite AR variants — granite-4.0-1b-speech, + * granite-speech-4.1-2b, and -2b-plus); other families ignore + * it. Caller-owned with the same lifetime as language/ + * target_language: copied before the call returns. The caller + * controls the exact list surface (separators, order, casing); + * the library only wraps it in each family's trained biasing + * clause. */ struct transcribe_run_params { uint64_t struct_size; @@ -1099,6 +1115,15 @@ struct transcribe_run_params { * to know whether the field will take effect. */ int32_t spec_k_drafts; + + /* + * hotwords: optional keyword-biasing hint (NUL-terminated UTF-8), or + * NULL for none. See the field docs above transcribe_run_params. + * Appended (only when non-empty) after spec_k_drafts as a tail field: + * old callers whose struct_size predates it are unaffected, and + * families guard the read behind struct_size. + */ + const char * hotwords; }; TRANSCRIBE_API void transcribe_run_params_init(struct transcribe_run_params * params); @@ -1327,6 +1352,14 @@ TRANSCRIBE_API transcribe_status transcribe_model_get_capabilities(const struct * against a model where this returns false emits * a WARN and proceeds. * + * HOTWORDS The model honors transcribe_run_params::hotwords: + * the runtime appends the caller's keyword list to + * the model's trained biasing clause so decoding is + * biased toward those terms. False means the field + * is ignored by that family (no error). A non-NULL + * hotwords string against a model where this returns + * false is silently ignored. + * * Returns false on NULL model or unknown feature enum. */ typedef enum { @@ -1337,6 +1370,7 @@ typedef enum { TRANSCRIBE_FEATURE_PNC = 4, TRANSCRIBE_FEATURE_ITN = 5, TRANSCRIBE_FEATURE_DIARIZATION = 6, + TRANSCRIBE_FEATURE_HOTWORDS = 7, } transcribe_feature; TRANSCRIBE_API bool transcribe_model_supports(const struct transcribe_model * model, transcribe_feature feature); diff --git a/scripts/dump_reference_granite_transformers.py b/scripts/dump_reference_granite_transformers.py index 7b9f43bf..08b64302 100755 --- a/scripts/dump_reference_granite_transformers.py +++ b/scripts/dump_reference_granite_transformers.py @@ -53,6 +53,31 @@ sys.path.insert(0, str(Path(__file__).parent)) from lib.ref_dump import write_tensor, write_transcript +# Per-variant base ASR instruction, matching the C++ granite path +# (build_granite_affixes in src/arch/granite/model.cpp) and the WER runner +# (scripts/wer/run_reference_granite_transformers.py). base-2b is trained on the +# punctuated prompt; -plus keeps a leading space (BPE tokenizes " can" vs "can" +# differently); 1b and any other variant use the plain-ASR prompt. +PLAIN_ASR_INSTRUCTION = "can you transcribe the speech into a written format?" +PLUS_ASR_INSTRUCTION = " can you transcribe the speech into a written format?" +BASE_2B_ASR_INSTRUCTION = "transcribe the speech with proper punctuation and capitalization." +BASE_2B_VARIANT = "granite-speech-4.1-2b" +PLUS_VARIANT = "granite-speech-4.1-2b-plus" +# Keyword biasing on base-2b uses a DISTINCT trained stem ("transcribe the speech +# to text.") that does NOT reuse the punctuated ASR prompt above. +BASE_2B_ASR_KWB_STEM = "transcribe the speech to text." + + +def default_instruction_for_variant(variant: str) -> str: + """Auto-selected base instruction when --instruction is omitted, mirroring + the WER runner and the C++ granite path so a bare parity dump already matches + without the operator having to know each variant's trained prompt.""" + if variant == BASE_2B_VARIANT: + return BASE_2B_ASR_INSTRUCTION + if variant == PLUS_VARIANT: + return PLUS_ASR_INSTRUCTION + return PLAIN_ASR_INSTRUCTION + def configure_torch(args: argparse.Namespace) -> None: import torch @@ -333,8 +358,28 @@ def cmd_decode(args: argparse.Namespace) -> int: f"Granite Speech expects 16kHz audio; got {sr} Hz in {audio_path}" ) - # Build the chat-templated prompt with an audio placeholder. - user_message = f"<|audio|>{args.instruction}" + # Build the chat-templated prompt with an audio placeholder. The base + # instruction is auto-selected per variant when --instruction is omitted, + # mirroring the WER runner and the C++ granite path (build_granite_affixes in + # src/arch/granite/model.cpp), so a bare parity dump matches without the + # operator having to know each variant's trained prompt. Keyword biasing + # (KWB) uses a DISTINCT trained stem that varies by variant: base-2b swaps to + # "transcribe the speech to text." (ASR) or "translate the speech to ." + # (AST), whereas -plus/1b append " Keywords: " to their plain-ASR + # prompt. The surface form is load-bearing (a paraphrase silently disables + # biasing), so only base-2b's auto-selected stem is swapped here. An explicit + # --instruction is honored verbatim (pass the trained AST-KWB stem there for + # translate parity). + variant = args.model.rstrip("/").rsplit("/", 1)[-1] + if args.instruction is not None: + instruction = args.instruction + else: + instruction = default_instruction_for_variant(variant) + if getattr(args, "hotwords", None): + if args.instruction is None and variant == BASE_2B_VARIANT: + instruction = BASE_2B_ASR_KWB_STEM + instruction = f"{instruction} Keywords: {args.hotwords}" + user_message = f"<|audio|>{instruction}" chat: list[dict] = [] if args.system: chat.append({"role": "system", "content": args.system}) @@ -505,8 +550,24 @@ def add_common_args(p: argparse.ArgumentParser) -> None: p.add_argument("--out", required=True, help="output directory for dumps") p.add_argument( "--instruction", - default="can you transcribe the speech into a written format?", - help="user instruction following the <|audio|> placeholder", + default=None, + help="User instruction after the <|audio|> placeholder. When omitted, " + "auto-selected per-variant from --model to match the model card and " + "the C++ granite path: base-2b -> 'transcribe the speech with proper " + "punctuation and capitalization.'; -plus -> ' can you transcribe the " + "speech into a written format?' (leading space); 1b -> 'can you " + "transcribe the speech into a written format?'. Override only if you " + "know what you are doing.", + ) + p.add_argument( + "--hotwords", + default=None, + help=("Optional caller-joined keyword list (e.g. \"kubernetes, gRPC\"); " + "appends IBM's trained \" Keywords: \" clause. Keyword biasing " + "on granite-speech-4.1-2b uses a distinct trained stem " + "(\"transcribe the speech to text.\"), so when --instruction is " + "omitted this swaps the auto-selected base-2b stem to match the C++ " + "granite path."), ) p.add_argument( "--system", diff --git a/scripts/dump_reference_moss_author.py b/scripts/dump_reference_moss_author.py index 72ddb131..79af06ea 100644 --- a/scripts/dump_reference_moss_author.py +++ b/scripts/dump_reference_moss_author.py @@ -62,6 +62,21 @@ ) +# Hotword-biasing label appended directly after DEFAULT_PROMPT's closing "。" +# (fullwidth colon, no trailing space), followed by the caller-joined list — +# mirrors the C++ moss runtime assembly (k_moss_hotword_label) and OpenMOSS +# examples/prompts.md so parity dumps tokenize identically. +MOSS_HOTWORD_LABEL = "热词提示:" + + +def build_prompt(hotwords: str | None) -> str: + """DEFAULT_PROMPT, optionally with a `热词提示:` hotword clause.""" + prompt = DEFAULT_PROMPT + if hotwords: + prompt += MOSS_HOTWORD_LABEL + hotwords + return prompt + + # --------------------------------------------------------------------------- # Shared reference-dump helpers # --------------------------------------------------------------------------- @@ -230,7 +245,9 @@ def make_source( "audio": audio_path.name, "n_samples": int(n_samples), "sample_rate": int(sample_rate), - "prompt": "DEFAULT_PROMPT (timestamp+diarize, zh)", + "prompt": "DEFAULT_PROMPT (timestamp+diarize, zh)" + + ("+hotwords" if getattr(args, "hotwords", None) else ""), + "hotwords": getattr(args, "hotwords", None), } @@ -373,7 +390,7 @@ def cmd_decode(args: argparse.Namespace) -> int: "role": "user", "content": [ {"type": "audio", "audio": str(args.audio)}, - {"type": "text", "text": DEFAULT_PROMPT}, + {"type": "text", "text": build_prompt(args.hotwords)}, ], } ] @@ -483,6 +500,9 @@ def _add_common(sp: argparse.ArgumentParser) -> None: sp.add_argument("--revision", default=None, help="HF revision to pin (ignored for local paths).") sp.add_argument("--audio", required=True, help="16 kHz mono WAV path") + sp.add_argument("--hotwords", default=None, + help="Optional caller-joined hotword list (e.g. \"热词1, 热词2\"); " + "appended as a 热词提示: clause to match the C++ path.") sp.add_argument("--out", required=True, help="Output directory for dumps") sp.add_argument("--device", default="cpu", help="torch device (default: cpu)") sp.add_argument("--dtype", default="bf16", choices=["bf16", "f16", "f32"], diff --git a/scripts/wer/run_reference_granite_transformers.py b/scripts/wer/run_reference_granite_transformers.py index c817d7bb..72f30526 100644 --- a/scripts/wer/run_reference_granite_transformers.py +++ b/scripts/wer/run_reference_granite_transformers.py @@ -60,6 +60,16 @@ def main() -> int: "punctuation and capitalization.' " "Override only if you know what you are doing.", ) + p.add_argument( + "--hotwords", + default=None, + help="Optional caller-joined keyword list (e.g. \"kubernetes, gRPC\"); " + "appends IBM's trained \" Keywords: \" clause. Keyword biasing " + "on granite-speech-4.1-2b uses a distinct trained stem " + "(\"transcribe the speech to text.\"), so when --instruction is " + "omitted this swaps the auto-selected base-2b stem to match the C++ " + "granite path.", + ) p.add_argument( "--system-prompt", default=None, @@ -174,7 +184,20 @@ def main() -> int: else: system_content = "" - # Build the prompt once. Same prompt every utterance. + # Build the prompt once. Same prompt every utterance. Keyword biasing (KWB) + # uses a DISTINCT trained stem that varies by variant, mirroring the C++ + # granite path (build_granite_affixes in src/arch/granite/model.cpp): + # granite-speech-4.1-2b swaps to "transcribe the speech to text." (ASR) or + # "translate the speech to ." (AST); -plus/1b instead append + # " Keywords: " to their plain-ASR prompt. The surface form is + # load-bearing (a paraphrase silently disables biasing), so only base-2b's + # auto-selected stem is swapped here. An explicit --instruction is honored + # verbatim — pass the trained AST-KWB stem there for translate parity, which + # this harness expresses only via --instruction. + if args.hotwords: + if args.instruction is None and variant == "granite-speech-4.1-2b": + instruction = "transcribe the speech to text." + instruction = f"{instruction} Keywords: {args.hotwords}" user_message = f"<|audio|>{instruction}" chat = [] if system_content: diff --git a/scripts/wer/run_reference_moss_author.py b/scripts/wer/run_reference_moss_author.py index 5dfcbdec..e2158818 100644 --- a/scripts/wer/run_reference_moss_author.py +++ b/scripts/wer/run_reference_moss_author.py @@ -40,6 +40,10 @@ "并在段末标注结束时间戳,以清晰标明该段语音范围。" ) +# Hotword-biasing label appended directly after DEFAULT_PROMPT (fullwidth colon, +# no trailing space) then the caller-joined list — mirrors the C++ moss runtime. +MOSS_HOTWORD_LABEL = "热词提示:" + def dediarize(raw: str) -> str: return " ".join(re.sub(r"\[[^\]]*\]", " ", raw).split()) @@ -64,6 +68,9 @@ def main() -> int: p.add_argument("--torch-threads", type=int, default=0, help="torch.set_num_threads (0 = unchanged).") p.add_argument("--max-new-tokens", type=int, default=1024) + p.add_argument("--hotwords", default=None, + help="Optional caller-joined hotword list (e.g. \"热词1, 热词2\"); " + "appended as a 热词提示: clause to match the C++ path.") p.add_argument("--limit", type=int, default=0, help="Process only the first N utterances (0 = all).") args = p.parse_args() @@ -100,12 +107,13 @@ def main() -> int: # Build the prompt text once (audio value is irrelevant to template render; # the pcm is passed per-utterance to processor(audio=...)). + prompt = DEFAULT_PROMPT + (MOSS_HOTWORD_LABEL + args.hotwords if args.hotwords else "") messages = [ { "role": "user", "content": [ {"type": "audio", "audio": ""}, - {"type": "text", "text": DEFAULT_PROMPT}, + {"type": "text", "text": prompt}, ], } ] @@ -130,7 +138,9 @@ def main() -> int: "load_ms": round(load_ms, 1), "framework": "moss_author", "model": args.model, - "prompt": "DEFAULT_PROMPT (timestamp+diarize, zh)", + "prompt": "DEFAULT_PROMPT (timestamp+diarize, zh)" + + ("+hotwords" if args.hotwords else ""), + "hotwords": args.hotwords, "dediarized": True, }) + "\n") fout.flush() diff --git a/src/arch/granite/capabilities.cpp b/src/arch/granite/capabilities.cpp index 220b2a12..cf8827f2 100644 --- a/src/arch/granite/capabilities.cpp +++ b/src/arch/granite/capabilities.cpp @@ -26,6 +26,12 @@ void apply_family_invariants(transcribe_model & model) { // not apply to granite (the LLM produces a single greedy // transcript per utterance). No PNC/ITN runtime toggle. transcribe::set_feature(&model, TRANSCRIBE_FEATURE_CANCELLATION, true); + + // Keyword biasing (KWB): the granite-speech cards document appending a + // trained " Keywords: " clause to the task instruction to bias + // decoding. Honored via transcribe_run_params::hotwords in + // build_granite_affixes(). + transcribe::set_feature(&model, TRANSCRIBE_FEATURE_HOTWORDS, true); } } // namespace transcribe::granite diff --git a/src/arch/granite/granite.h b/src/arch/granite/granite.h index 7a89b0d5..83a0baf1 100644 --- a/src/arch/granite/granite.h +++ b/src/arch/granite/granite.h @@ -51,6 +51,20 @@ namespace transcribe::granite { void apply_family_invariants(transcribe_model & model); +// Select the exact user-turn instruction string for a granite run from the +// variant name and resolved run params. Pure and model-free: the caller +// resolves diarize_on (diarize_requested) and passes it in, and no tokenizer is +// touched. This is the single source of truth for the trained prompt surface +// form — a paraphrase is silently ignored by the model — which lets +// granite_prompt_builder_unit assert every (variant × task × hotwords) +// combination exactly. Returns TRANSCRIBE_ERR_INVALID_ARG for a missing/ +// unadvertised translate target or diarize combined with word timestamps, +// matching the prompt builder used at decode time. +transcribe_status select_granite_instruction(const std::string & variant, + const transcribe_run_params * params, + bool diarize_on, + std::string & out_instruction); + // Chat-template token ids resolved through the loaded tokenizer at load time // (bare-USER:/ASSISTANT: for 1b/2b, Granite-4 system-role for -plus). Every // piece is resolved ahead of time so vocab drift fails loudly at load instead diff --git a/src/arch/granite/model.cpp b/src/arch/granite/model.cpp index c66354f6..cc5e6374 100644 --- a/src/arch/granite/model.cpp +++ b/src/arch/granite/model.cpp @@ -513,28 +513,37 @@ static const char * granite_target_language_name(const char * code_or_name) { return nullptr; } -// Build the prompt prefix/suffix token-id lists from the shared run params and -// model variant (the audio tokens splice in between). Single source of truth -// for run() and run_batch(). -static transcribe_status build_granite_affixes(GraniteModel * cm, - const transcribe_run_params * params, - std::vector & prefix_ids, - std::vector & suffix_ids) { +} // namespace + +// Select the exact granite user-turn instruction. Declared in granite.h (has +// external linkage) so granite_prompt_builder_unit can assert the surface form +// for every variant × task × hotwords combination. Model-free: the caller +// resolves diarize_on and passes it in. +transcribe_status select_granite_instruction(const std::string & variant, + const transcribe_run_params * params, + bool diarize_on, + std::string & out_instruction) { std::string instruction; - if (cm->hparams.variant == "granite-speech-4.1-2b") { + if (variant == "granite-speech-4.1-2b") { instruction = "transcribe the speech with proper punctuation and capitalization."; - } else if (cm->hparams.variant == "granite-speech-4.1-2b-plus") { + } else if (variant == "granite-speech-4.1-2b-plus") { instruction = " can you transcribe the speech into a written format?"; } else { instruction = "can you transcribe the speech into a written format?"; } + + // Resolved once in the translate branch and reused by the AST+KWB stem so the + // language is never looked up (and never concatenated as a possible nullptr) + // twice. + const char * lang_name = nullptr; + if (params != nullptr) { if (params->task == TRANSCRIBE_TASK_TRANSLATE) { if (params->target_language == nullptr || params->target_language[0] == '\0') { log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, "granite: translate task requires --target-language"); return TRANSCRIBE_ERR_INVALID_ARG; } - const char * lang_name = granite_target_language_name(params->target_language); + lang_name = granite_target_language_name(params->target_language); if (lang_name == nullptr) { log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, "granite: target_language '%s' is not advertised", params->target_language); @@ -545,7 +554,7 @@ static transcribe_status build_granite_affixes(GraniteModel * cm, // -plus only (1b/2b advertise NONE, gated out upstream). AUTO does // NOT request timestamps. IBM's verbatim prompt; the model emits // per-word "[T:N]" centisecond markers (parsed in run()). - if (diarize_requested(cm, params)) { + if (diarize_on) { // Upstream defines timestamps and speaker attribution as // separate tasks (one instruction each); they do not compose. log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, @@ -556,12 +565,69 @@ static transcribe_status build_granite_affixes(GraniteModel * cm, instruction = " Timestamps: Transcribe the speech. After each word, add a timestamp tag " "showing the end time in centiseconds, e.g. hello [T:45] world [T:82]"; - } else if (diarize_requested(cm, params)) { + } else if (diarize_on) { // -plus only (the DIARIZATION feature bit gates this). IBM's // verbatim speaker-attribution instruction; the model emits // "[Speaker N]:" tags before turns (split after decode). instruction = k_saa_instruction; } + + // Keyword biasing (KWB) uses a DISTINCT trained instruction per task and + // variant — not an arbitrary suffix on the active prompt. An + // off-distribution prompt is silently ignored (the model falls back to + // plain transcription), so the base sentence has to match what was + // trained. IBM's granite-speech-4.1-2b card documents the KWB stems as + // "transcribe the speech to text. Keywords: " (ASR) and "translate + // the speech to . Keywords: " (AST) — neither reuses the + // punctuated-ASR sentence. The -plus card instead appends to its + // plain-ASR prompt ("... written format? Keywords: "), and 1b + // publishes no KWB stem and has no punctuated variant, so it follows the + // same plain-ASR-append form. So only base-2b needs its stem swapped; + // -plus/1b already carry the correct plain-ASR instruction to append to. + if (const char * hw = transcribe::run_params_hotwords(params)) { + const bool ts_task = params->timestamps == TRANSCRIBE_TIMESTAMPS_WORD; + const bool base_2b = variant == "granite-speech-4.1-2b"; + if (ts_task || diarize_on) { + // -plus-only rich-transcription tasks. IBM documents KWB for + // ASR/AST only, so keep the task instruction and append + // best-effort, warning that the combination is unattested. + log_msg(TRANSCRIBE_LOG_LEVEL_WARN, + "granite: hotwords combined with %s is unverified — IBM documents keyword " + "biasing for ASR/AST only; the combination may weaken task output such as " + "[Speaker N]: tags", + ts_task ? "word timestamps" : "diarization"); + } else if (base_2b && params->task == TRANSCRIBE_TASK_TRANSLATE) { + // AST + KWB on base-2b: attested stem. lang_name was resolved and + // validated in the translate branch above, so it is non-null. + instruction = std::string("translate the speech to ") + lang_name + "."; + } else if (base_2b) { + // ASR + KWB on base-2b: the trained stem differs from the + // punctuated-ASR instruction selected above. + instruction = "transcribe the speech to text."; + } + instruction += " Keywords: "; + instruction += hw; + } + } + + out_instruction = std::move(instruction); + return TRANSCRIBE_OK; +} + +namespace { + +// Build the prompt prefix/suffix token-id lists from the shared run params and +// model variant (the audio tokens splice in between). Single source of truth +// for run() and run_batch(). +static transcribe_status build_granite_affixes(GraniteModel * cm, + const transcribe_run_params * params, + std::vector & prefix_ids, + std::vector & suffix_ids) { + std::string instruction; + if (const transcribe_status st = + select_granite_instruction(cm->hparams.variant, params, diarize_requested(cm, params), instruction); + st != TRANSCRIBE_OK) { + return st; } const bool use_granite4_chat = cm->chat_template.find("<|start_of_role|>") != std::string::npos && diff --git a/src/arch/moss/capabilities.cpp b/src/arch/moss/capabilities.cpp index 329411bb..f10bc1ab 100644 --- a/src/arch/moss/capabilities.cpp +++ b/src/arch/moss/capabilities.cpp @@ -20,6 +20,11 @@ void apply_family_invariants(transcribe_model & model) { transcribe::set_feature(&model, TRANSCRIBE_FEATURE_CANCELLATION, true); transcribe::set_feature(&model, TRANSCRIBE_FEATURE_DIARIZATION, true); + + // Hotword biasing: the runtime appends a "热词提示:" clause to the + // baked prompt (see build_prompt_tokens / diarize split). Honored via + // transcribe_run_params::hotwords. + transcribe::set_feature(&model, TRANSCRIBE_FEATURE_HOTWORDS, true); } } // namespace transcribe::moss diff --git a/src/arch/moss/model.cpp b/src/arch/moss/model.cpp index abe4b3d7..6b2cdfb4 100644 --- a/src/arch/moss/model.cpp +++ b/src/arch/moss/model.cpp @@ -177,10 +177,11 @@ void build_audio_span(const MossHParams & hp, } } -void build_prompt_tokens(const MossHParams & hp, - int audio_seq_len, - std::vector & out_ids, - std::vector & out_audio_positions) { +void build_prompt_tokens(const MossHParams & hp, + int audio_seq_len, + const std::vector & hotword_ids, + std::vector & out_ids, + std::vector & out_audio_positions) { out_ids.clear(); out_audio_positions.clear(); @@ -195,7 +196,20 @@ void build_prompt_tokens(const MossHParams & hp, out_audio_positions.push_back(prefix_len + off); } - out_ids.insert(out_ids.end(), hp.prompt_suffix_tokens.begin(), hp.prompt_suffix_tokens.end()); + // Suffix, split at the baked prompt body->close boundary so optional + // hotword ids land inside the prompt (after "…语音范围。", before the + // <|im_end|> that closes the turn). Empty hotword_ids => byte-identical to + // the original single-array insert. When no eos was found at load time the + // split equals the suffix size (no body->close boundary); in that case + // hotwords are disabled rather than appended after the turn-closing tokens, + // honoring the load-time contract and never corrupting the baked prompt. + const auto & suffix = hp.prompt_suffix_tokens; + const size_t split = std::min(hp.prompt_suffix_split, suffix.size()); + out_ids.insert(out_ids.end(), suffix.begin(), suffix.begin() + static_cast(split)); + if (split < suffix.size()) { + out_ids.insert(out_ids.end(), hotword_ids.begin(), hotword_ids.end()); + } + out_ids.insert(out_ids.end(), suffix.begin() + static_cast(split), suffix.end()); } namespace { @@ -203,6 +217,35 @@ namespace { constexpr const char k_default_variant[] = "moss-transcribe-diarize"; constexpr int k_max_new = 256; +// Hotword biasing clause label. Appended directly after the baked prompt's +// closing "。" (no separating space), then the caller's comma-joined list — +// matching OpenMOSS examples/prompts.md ("…语音范围。热词提示:热词1, 热词2"). +// Chinese label because the baked default prompt is Chinese. +constexpr const char k_moss_hotword_label[] = "热词提示:"; + +// Encode the "热词提示:" clause for insertion at the prompt body->close +// boundary. Returns empty (unbiased prompt) when no hotwords are supplied, the +// GGUF tokenizer lacks a runtime encoder (merges absent), or encoding fails. +std::vector build_moss_hotword_ids(const MossModel * cm, const transcribe_run_params * params) { + std::vector ids; + const char * hw = transcribe::run_params_hotwords(params); + if (hw == nullptr) { + return ids; + } + if (!cm->tok.has_encoder()) { + log_msg(TRANSCRIBE_LOG_LEVEL_WARN, + "moss: hotwords ignored — GGUF tokenizer has no runtime encoder (merges absent)"); + return ids; + } + const std::string text = std::string(k_moss_hotword_label) + hw; + if (const transcribe_status st = cm->tok.encode(text, ids); st != TRANSCRIBE_OK) { + log_msg(TRANSCRIBE_LOG_LEVEL_WARN, "moss: hotwords ignored — tokenizer encode failed (%d)", + static_cast(st)); + ids.clear(); + } + return ids; +} + int moss_context_ceiling(int32_t n_ctx_knob, const MossHParams & hp) { int ceiling = hp.dec_max_position_embeddings; if (n_ctx_knob > 0 && n_ctx_knob < ceiling) { @@ -253,6 +296,24 @@ transcribe_status load(Loader & loader, const transcribe_model_load_params * par return TRANSCRIBE_ERR_GGUF; } + // Split the baked suffix at the first eos id (the <|im_end|> closing the + // prompt body). DEFAULT_PROMPT is plain text with no control tokens, so the + // first eos in the suffix is exactly the body->close boundary. Hotwords, if + // supplied, are inserted here at run time (build_prompt_tokens). If no eos + // is present, split = size (no boundary), so hotwords are disabled — never + // appended after the turn-closing tokens and never corrupts the baked prompt. + { + const auto & suffix = m->hparams.prompt_suffix_tokens; + size_t split = suffix.size(); + for (size_t i = 0; i < suffix.size(); ++i) { + if (suffix[i] == m->hparams.eos_token_id) { + split = i; + break; + } + } + m->hparams.prompt_suffix_split = split; + } + // Publish an advisory input bound (decoder context / audio-token rate). if (m->hparams.dec_max_position_embeddings > 0 && m->hparams.fe_hop_length > 0 && m->hparams.fe_sample_rate > 0) { m->limits.has_context_cap = true; @@ -662,9 +723,10 @@ transcribe_status run(transcribe_session * session, } // Prompt. - std::vector prompt_ids; - std::vector audio_positions; - build_prompt_tokens(cm->hparams, T_enc, prompt_ids, audio_positions); + std::vector prompt_ids; + std::vector audio_positions; + const std::vector hotword_ids = build_moss_hotword_ids(cm, params); + build_prompt_tokens(cm->hparams, T_enc, hotword_ids, prompt_ids, audio_positions); const int T_prompt = static_cast(prompt_ids.size()); if (static_cast(audio_positions.size()) != T_enc) { log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, "moss run: audio_positions(%zu) != T_enc(%d)", audio_positions.size(), @@ -1018,9 +1080,11 @@ transcribe_status run_batch(transcribe_session * session, std::vector fail_status(n, TRANSCRIBE_ERR_INVALID_ARG); int64_t mel_us = 0, enc_us = 0; - const int ceiling = moss_context_ceiling(cc->n_ctx, cm->hparams); - int max_T_prompt = 0; - int max_T_enc = 0; + const int ceiling = moss_context_ceiling(cc->n_ctx, cm->hparams); + int max_T_prompt = 0; + int max_T_enc = 0; + // One hotword clause for the whole batch (params->hotwords is shared). + const std::vector hotword_ids = build_moss_hotword_ids(cm, params); for (int b = 0; b < n; ++b) { if (cc->poll_abort()) { return TRANSCRIBE_ERR_ABORTED; @@ -1036,7 +1100,7 @@ transcribe_status run_batch(transcribe_session * session, continue; } T_enc[b] = te; - build_prompt_tokens(cm->hparams, te, prompt_ids[b], audio_positions[b]); + build_prompt_tokens(cm->hparams, te, hotword_ids, prompt_ids[b], audio_positions[b]); T_prompt[b] = static_cast(prompt_ids[b].size()); if (T_prompt[b] + k_max_new > ceiling) { fail_status[b] = TRANSCRIBE_ERR_INPUT_TOO_LONG; diff --git a/src/arch/moss/moss.h b/src/arch/moss/moss.h index 515e4792..5805e560 100644 --- a/src/arch/moss/moss.h +++ b/src/arch/moss/moss.h @@ -51,14 +51,17 @@ void build_audio_span(const MossHParams & hp, std::vector & out_span_ids, std::vector & out_audio_offsets); -// Assemble the full prompt: prefix_tokens + audio_span + suffix_tokens. +// Assemble the full prompt: prefix_tokens + audio_span + suffix, with optional +// hotword ids inserted at the baked suffix's body->close split (hp. +// prompt_suffix_split). Pass an empty hotword_ids for the unbiased prompt. // out_audio_positions holds the absolute prompt positions of the audio_pad // tokens (in order), so the b-th audio feature is scattered to // input_ids[out_audio_positions[b]]. -void build_prompt_tokens(const MossHParams & hp, - int audio_seq_len, - std::vector & out_ids, - std::vector & out_audio_positions); +void build_prompt_tokens(const MossHParams & hp, + int audio_seq_len, + const std::vector & hotword_ids, + std::vector & out_ids, + std::vector & out_audio_positions); struct MossModel final : public transcribe_model { Tokenizer tok; diff --git a/src/arch/moss/weights.h b/src/arch/moss/weights.h index fcfd540b..6e8ac091 100644 --- a/src/arch/moss/weights.h +++ b/src/arch/moss/weights.h @@ -66,6 +66,15 @@ struct MossHParams { std::vector prompt_suffix_tokens; std::vector digit_tokens; // ids for '0'..'9' + // Runtime split of prompt_suffix_tokens at the first eos_token_id (the + // <|im_end|> that closes the baked prompt body): suffix body = tokens + // [0, prompt_suffix_split), generation-close tail = [prompt_suffix_split, + // end). Optional hotword ids are inserted at the boundary. Resolved at + // load once eos_token_id is known. When no eos id is present (unexpected), + // it equals prompt_suffix_tokens.size(), so there is no boundary and + // hotwords are disabled — the baked prompt is emitted verbatim. + size_t prompt_suffix_split = 0; + // Token ids (resolved from tokenizer KV at load). int32_t bos_token_id = -1; int32_t eos_token_id = -1; diff --git a/src/transcribe-arch.h b/src/transcribe-arch.h index c88d6380..3ce977a3 100644 --- a/src/transcribe-arch.h +++ b/src/transcribe-arch.h @@ -11,6 +11,8 @@ #include "transcribe.h" +#include + namespace transcribe { class Loader; @@ -135,4 +137,24 @@ struct Arch { // family matches. const Arch * find_arch(const char * name); +// Guarded read of the optional transcribe_run_params::hotwords tail field. +// Returns nullptr when params is NULL, when the caller's struct_size predates +// the field (old ABI), or when the string is NULL/empty. Families call this +// instead of touching params->hotwords directly so a short-struct caller never +// causes an out-of-bounds read. See the field docs in transcribe.h. +inline const char * run_params_hotwords(const transcribe_run_params * params) { + if (params == nullptr) { + return nullptr; + } + const size_t field_end = offsetof(transcribe_run_params, hotwords) + sizeof(params->hotwords); + if (params->struct_size < static_cast(field_end)) { + return nullptr; + } + const char * hw = params->hotwords; + if (hw == nullptr || hw[0] == '\0') { + return nullptr; + } + return hw; +} + } // namespace transcribe diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e4b12330..fd8a711d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -202,6 +202,22 @@ transcribe_apply_warnings(transcribe_moss_diarize_parser_unit) add_test(NAME transcribe_moss_diarize_parser_unit COMMAND transcribe_moss_diarize_parser_unit) +# ----------------------------------------------------------------------------- +# MOSS hotword prompt-builder unit test (pure host, no model) +# ----------------------------------------------------------------------------- + +add_executable(transcribe_moss_prompt_builder_unit + moss_prompt_builder_unit.cpp) + +target_link_libraries(transcribe_moss_prompt_builder_unit PRIVATE transcribe ggml) + +target_include_directories(transcribe_moss_prompt_builder_unit PRIVATE + ${CMAKE_SOURCE_DIR}/src) + +transcribe_apply_warnings(transcribe_moss_prompt_builder_unit) + +add_test(NAME transcribe_moss_prompt_builder_unit COMMAND transcribe_moss_prompt_builder_unit) + # ----------------------------------------------------------------------------- # Granite speaker-attribution splitter unit test (pure host, no model) # ----------------------------------------------------------------------------- @@ -218,6 +234,22 @@ transcribe_apply_warnings(transcribe_granite_diarize_parser_unit) add_test(NAME transcribe_granite_diarize_parser_unit COMMAND transcribe_granite_diarize_parser_unit) +# ----------------------------------------------------------------------------- +# Granite instruction-selector unit test (pure host, no model) +# ----------------------------------------------------------------------------- + +add_executable(transcribe_granite_prompt_builder_unit + granite_prompt_builder_unit.cpp) + +target_link_libraries(transcribe_granite_prompt_builder_unit PRIVATE transcribe ggml) + +target_include_directories(transcribe_granite_prompt_builder_unit PRIVATE + ${CMAKE_SOURCE_DIR}/src) + +transcribe_apply_warnings(transcribe_granite_prompt_builder_unit) + +add_test(NAME transcribe_granite_prompt_builder_unit COMMAND transcribe_granite_prompt_builder_unit) + # ----------------------------------------------------------------------------- # F16 → F32 conv pointwise promotion unit test # ----------------------------------------------------------------------------- diff --git a/tests/granite_prompt_builder_unit.cpp b/tests/granite_prompt_builder_unit.cpp new file mode 100644 index 00000000..2a73fde3 --- /dev/null +++ b/tests/granite_prompt_builder_unit.cpp @@ -0,0 +1,190 @@ +// granite_prompt_builder_unit.cpp - table-driven tests for the granite +// user-turn instruction selector (arch/granite/granite.h:: +// select_granite_instruction). Pure host-side: no model, no GGUF, no +// tokenizer. Granite's keyword-biasing surface form is load-bearing (a +// paraphrase is silently ignored by the model and biasing does nothing), and +// the stem is DISTINCT per variant × task, so these tests pin the exact +// instruction string for every combination — the shape of bug that already +// slipped through once (base-2b appending Keywords to the punctuated-ASR stem +// instead of the trained "transcribe the speech to text." stem). + +#include "arch/granite/diarize.h" +#include "arch/granite/granite.h" +#include "transcribe.h" + +#include +#include +#include +#include + +namespace { + +int g_failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++g_failures; \ + } \ + } while (0) + +// Compare the selected instruction against an expected string. `diarize_on` is +// the pre-resolved diarize_requested() result the decode path passes in. +void expect_instruction(const char * variant, + const transcribe_run_params * params, + bool diarize_on, + const std::string & want) { + std::string got; + const transcribe_status st = transcribe::granite::select_granite_instruction(variant, params, diarize_on, got); + if (st != TRANSCRIBE_OK) { + std::fprintf(stderr, "FAIL %s: select returned status %d (expected OK)\n", variant, (int) st); + ++g_failures; + return; + } + if (got != want) { + std::fprintf(stderr, "FAIL %s:\n got: '%s'\n want: '%s'\n", variant, got.c_str(), want.c_str()); + ++g_failures; + } +} + +void expect_error(const char * variant, const transcribe_run_params * params, bool diarize_on, transcribe_status want) { + std::string got; + const transcribe_status st = transcribe::granite::select_granite_instruction(variant, params, diarize_on, got); + CHECK(st == want); +} + +// Per-variant plain-ASR instructions (the leading space on -plus is a real BPE +// nuance: " can" tokenizes differently from "can"). +constexpr const char * k_base_2b_asr = "transcribe the speech with proper punctuation and capitalization."; +constexpr const char * k_plus_asr = " can you transcribe the speech into a written format?"; +constexpr const char * k_1b_asr = "can you transcribe the speech into a written format?"; + +void test_null_params_defaults_per_variant() { + // params == nullptr => plain-ASR default, one per variant. No task, no KWB. + expect_instruction("granite-speech-4.1-2b", nullptr, false, k_base_2b_asr); + expect_instruction("granite-speech-4.1-2b-plus", nullptr, false, k_plus_asr); + expect_instruction("granite-4.0-1b-speech", nullptr, false, k_1b_asr); + // An unrecognized variant falls through to the 1b/plain-ASR default. + expect_instruction("granite-speech-99", nullptr, false, k_1b_asr); +} + +void test_plain_asr_no_hotwords() { + transcribe_run_params p; + transcribe_run_params_init(&p); // task = TRANSCRIBE, timestamps = AUTO, no hotwords + expect_instruction("granite-speech-4.1-2b", &p, false, k_base_2b_asr); + expect_instruction("granite-speech-4.1-2b-plus", &p, false, k_plus_asr); + expect_instruction("granite-4.0-1b-speech", &p, false, k_1b_asr); +} + +void test_asr_hotwords_distinct_stem_per_variant() { + transcribe_run_params p; + transcribe_run_params_init(&p); + p.hotwords = "kubernetes, gRPC"; + + // base-2b swaps to the trained ASR-KWB stem (NOT the punctuated-ASR stem). + expect_instruction("granite-speech-4.1-2b", &p, false, "transcribe the speech to text. Keywords: kubernetes, gRPC"); + // -plus/1b append to their plain-ASR prompt verbatim (leading space kept). + expect_instruction("granite-speech-4.1-2b-plus", &p, false, + " can you transcribe the speech into a written format? Keywords: kubernetes, gRPC"); + expect_instruction("granite-4.0-1b-speech", &p, false, + "can you transcribe the speech into a written format? Keywords: kubernetes, gRPC"); +} + +void test_translate_no_hotwords() { + transcribe_run_params p; + transcribe_run_params_init(&p); + p.task = TRANSCRIBE_TASK_TRANSLATE; + p.target_language = "fr"; + // Same AST prompt across variants (capabilities gate which variants may run). + expect_instruction("granite-speech-4.1-2b", &p, false, "can you translate the speech into French?"); + expect_instruction("granite-4.0-1b-speech", &p, false, "can you translate the speech into French?"); + // Name / alias inputs resolve identically. + p.target_language = "German"; + expect_instruction("granite-speech-4.1-2b", &p, false, "can you translate the speech into German?"); +} + +void test_translate_hotwords_base_2b_stem() { + transcribe_run_params p; + transcribe_run_params_init(&p); + p.task = TRANSCRIBE_TASK_TRANSLATE; + p.target_language = "es"; + p.hotwords = "Mori, OTEL"; + // base-2b AST+KWB uses the trained "translate the speech to ." stem. + expect_instruction("granite-speech-4.1-2b", &p, false, "translate the speech to Spanish. Keywords: Mori, OTEL"); + // 1b has no attested AST-KWB stem: append to its plain translate prompt. + expect_instruction("granite-4.0-1b-speech", &p, false, + "can you translate the speech into Spanish? Keywords: Mori, OTEL"); +} + +void test_translate_requires_valid_target() { + transcribe_run_params p; + transcribe_run_params_init(&p); + p.task = TRANSCRIBE_TASK_TRANSLATE; + + // Missing target language. + p.target_language = nullptr; + expect_error("granite-speech-4.1-2b", &p, false, TRANSCRIBE_ERR_INVALID_ARG); + p.target_language = ""; + expect_error("granite-speech-4.1-2b", &p, false, TRANSCRIBE_ERR_INVALID_ARG); + + // Unadvertised target language. + p.target_language = "klingon"; + expect_error("granite-speech-4.1-2b", &p, false, TRANSCRIBE_ERR_INVALID_ARG); +} + +void test_word_timestamps() { + static const char * k_ts_instruction = + " Timestamps: Transcribe the speech. After each word, add a timestamp tag " + "showing the end time in centiseconds, e.g. hello [T:45] world [T:82]"; + + transcribe_run_params p; + transcribe_run_params_init(&p); + p.timestamps = TRANSCRIBE_TIMESTAMPS_WORD; + expect_instruction("granite-speech-4.1-2b-plus", &p, false, k_ts_instruction); + + // Timestamps + diarize are mutually exclusive tasks upstream. + expect_error("granite-speech-4.1-2b-plus", &p, /*diarize_on=*/true, TRANSCRIBE_ERR_INVALID_ARG); + + // Timestamps + hotwords: unattested combination, warns but appends best-effort. + p.hotwords = "gRPC"; + expect_instruction("granite-speech-4.1-2b-plus", &p, false, std::string(k_ts_instruction) + " Keywords: gRPC"); +} + +void test_diarize_speaker_attribution() { + const std::string saa = transcribe::granite::k_saa_instruction; + + transcribe_run_params p; + transcribe_run_params_init(&p); + // diarize_on resolved true by the caller (feature advertised + diarize=ON). + expect_instruction("granite-speech-4.1-2b-plus", &p, true, saa); + + // Diarize + hotwords: unattested, warns but appends the clause best-effort. + p.hotwords = "kubernetes"; + expect_instruction("granite-speech-4.1-2b-plus", &p, true, saa + " Keywords: kubernetes"); +} + +void test_hotwords_gated_by_struct_size() { + transcribe_run_params p; + transcribe_run_params_init(&p); + p.hotwords = "kubernetes"; + // A caller that predates the hotwords field (struct_size stops before it) is + // treated as "no hint" — base-2b keeps its plain-ASR stem, no Keywords clause. + p.struct_size = offsetof(transcribe_run_params, hotwords); + expect_instruction("granite-speech-4.1-2b", &p, false, k_base_2b_asr); +} + +} // namespace + +int main() { + test_null_params_defaults_per_variant(); + test_plain_asr_no_hotwords(); + test_asr_hotwords_distinct_stem_per_variant(); + test_translate_no_hotwords(); + test_translate_hotwords_base_2b_stem(); + test_translate_requires_valid_target(); + test_word_timestamps(); + test_diarize_speaker_attribution(); + test_hotwords_gated_by_struct_size(); + return g_failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} diff --git a/tests/moss_prompt_builder_unit.cpp b/tests/moss_prompt_builder_unit.cpp new file mode 100644 index 00000000..cdec20fe --- /dev/null +++ b/tests/moss_prompt_builder_unit.cpp @@ -0,0 +1,125 @@ +// moss_prompt_builder_unit.cpp - unit tests for MOSS hotword prompt assembly +// (arch/moss/moss.h::build_prompt_tokens) and the shared guarded hotwords +// accessor (transcribe-arch.h::run_params_hotwords). Pure host-side: no model, +// no GGUF. The invariants under test: +// * empty hotword_ids => prompt is byte-identical to the unbiased assembly +// (prefix + audio_span + baked suffix); +// * non-empty hotword_ids are inserted exactly at the baked suffix's +// body->close split, leaving prefix / audio span / suffix tail untouched; +// * audio_pad positions never shift when hotwords are inserted (they precede +// the suffix); +// * run_params_hotwords honors struct_size gating and NULL/empty as "no hint". + +#include "arch/moss/moss.h" +#include "transcribe-arch.h" +#include "transcribe.h" + +#include +#include +#include +#include +#include + +namespace { + +int g_failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++g_failures; \ + } \ + } while (0) + +// Minimal hparams for deterministic, model-free prompt assembly. Time markers +// disabled so the audio span is exactly `audio_seq_len` copies of audio_token_id +// (build_audio_span short-circuit), and the suffix carries an explicit +// body->close split (index 3 = the baked <|im_end|> that closes the turn). +transcribe::moss::MossHParams make_hparams() { + transcribe::moss::MossHParams hp; + hp.audio_token_id = 7; + hp.enable_time_marker = false; + hp.prompt_prefix_tokens = { 100, 101 }; + // suffix = tokens("…。") [200,201,202] + close [999(<|im_end|>),300]. + hp.prompt_suffix_tokens = { 200, 201, 202, 999, 300 }; + hp.prompt_suffix_split = 3; // insert hotwords after 202, before 999 + return hp; +} + +void test_no_hotwords_byte_identical() { + const transcribe::moss::MossHParams hp = make_hparams(); + std::vector ids, pos; + transcribe::moss::build_prompt_tokens(hp, /*audio_seq_len=*/3, /*hotword_ids=*/{}, ids, pos); + + const std::vector expect = { 100, 101, 7, 7, 7, 200, 201, 202, 999, 300 }; + CHECK(ids == expect); + // Audio pads sit right after the 2-token prefix. + const std::vector expect_pos = { 2, 3, 4 }; + CHECK(pos == expect_pos); +} + +void test_hotwords_inserted_at_split() { + const transcribe::moss::MossHParams hp = make_hparams(); + std::vector ids, pos; + const std::vector hotwords = { 5000, 5001 }; + transcribe::moss::build_prompt_tokens(hp, /*audio_seq_len=*/3, hotwords, ids, pos); + + // prefix | span | suffix[0,3) | hotwords | suffix[3,end) + const std::vector expect = { 100, 101, 7, 7, 7, 200, 201, 202, 5000, 5001, 999, 300 }; + CHECK(ids == expect); + // Inserting hotwords must NOT move the audio-pad positions (they precede + // the suffix), so decode still scatters features to the same slots. + const std::vector expect_pos = { 2, 3, 4 }; + CHECK(pos == expect_pos); +} + +void test_split_at_suffix_size_disables_hotwords() { + // Unexpected: no <|im_end|> found at load => split == suffix.size() (no + // body->close boundary). Hotwords are disabled rather than appended after + // the turn-closing tokens; the baked prompt is emitted verbatim. + transcribe::moss::MossHParams hp = make_hparams(); + hp.prompt_suffix_split = 999; // >> suffix size; must clamp to size + std::vector ids, pos; + const std::vector hotwords = { 5000 }; + transcribe::moss::build_prompt_tokens(hp, /*audio_seq_len=*/1, hotwords, ids, pos); + + // Baked prompt only, hotwords dropped: prefix | span | full suffix. + const std::vector expect = { 100, 101, 7, 200, 201, 202, 999, 300 }; + CHECK(ids == expect); +} + +void test_run_params_hotwords_guard() { + // NULL params. + CHECK(transcribe::run_params_hotwords(nullptr) == nullptr); + + transcribe_run_params params; + transcribe_run_params_init(¶ms); + + // Unset (init zeroed the pointer) => no hint. + CHECK(transcribe::run_params_hotwords(¶ms) == nullptr); + + // Empty string => no hint. + params.hotwords = ""; + CHECK(transcribe::run_params_hotwords(¶ms) == nullptr); + + // Valid string => returned verbatim. + params.hotwords = "kubernetes, gRPC"; + const char * hw = transcribe::run_params_hotwords(¶ms); + CHECK(hw != nullptr && std::string(hw) == "kubernetes, gRPC"); + + // struct_size too small to include the tail field => treated as absent even + // though the pointer is set (old caller that predates the field). + params.struct_size = offsetof(transcribe_run_params, hotwords); + CHECK(transcribe::run_params_hotwords(¶ms) == nullptr); +} + +} // namespace + +int main() { + test_no_hotwords_byte_identical(); + test_hotwords_inserted_at_split(); + test_split_at_suffix_size_disables_hotwords(); + test_run_params_hotwords_guard(); + return g_failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +}