diff --git a/.claude/skills/porting-3-convert/SKILL.md b/.claude/skills/porting-3-convert/SKILL.md index 9990e2e9..6d0585df 100644 --- a/.claude/skills/porting-3-convert/SKILL.md +++ b/.claude/skills/porting-3-convert/SKILL.md @@ -25,7 +25,7 @@ Convert progress: - [ ] Step 2: Identify reference dtype from intake - [ ] Step 3: Run the converter (reference dtype only) - [ ] Step 4: Write the converter manifest -- [ ] Step 5: Run structural check (Preflight Gate B) +- [ ] Step 5: Run structural checks (Preflight Gate B + loader smoke + quant-policy sync) - [ ] Step 6: Sign-off review ``` @@ -41,6 +41,34 @@ Preserve source dtypes, emit the loader KV used by `src/arch//weights.cpp`, and surface only unresolved tensor-name or sharding decisions to the user. +**Always build the writer via `gguf_writer()` from `lib.gguf_common`**, never +`gguf.GGUFWriter` directly: + +```python +from lib.gguf_common import gguf_writer +writer = gguf_writer(str(out_path), "") +``` + +`gguf_writer()` automatically relocates the bulk tokenizer KVs +(`tokenizer.ggml.tokens` / `scores` / `token_type` / `merges`, +`tokenizer.chat_template`) to a trailer after all scalar metadata, so remote +consumers can range-read the small metadata prefix without pulling the multi-MB +tokenizer tables. + +**Per-tensor dtype bucketing.** Converters choose each tensor's storage dtype via +`reference_dtype_for()` from `lib.gguf_common` (biases / norm scales / positional +tables / frontend buffers → F32; conv kernels → F16 when the reference dtype is +BF16, which the loader has no conv kernel for; everything else keeps the reference +dtype). This is a Python mirror of the canonical bucketing in +`tools/transcribe-quantize/policy.cpp::classify_tensor`, which the Stage 5 +quantizer uses. The two are hand-synced, so when this family needs a tensor kept +out of the reference dtype — a new norm/conv/positional name the loader requires +at F32/F16 — add the rule to **both** `reference_dtype_for` **and** +`policy.cpp::classify_tensor`, then add a representative tensor name for this +family to the corpus in `scripts/lib/test_quant_policy_sync.py`. That test +(Step 5) is what keeps the two copies from drifting; catching it here, at convert +time, avoids a wrong-dtype surprise when Stage 5 quantizes. + ### Step 2: Identify reference dtype (read intake) ```bash @@ -125,6 +153,14 @@ build/bin/transcribe-cli -m models//-.gguf samples/j For a brand-new family where `src/arch//` doesn't exist yet, the loader returns `TRANSCRIBE_ERR_UNSUPPORTED_ARCH`. That is acceptable at Stage 3 — note it in sign-off; Stage 4 (`porting-4-cpp`) brings up the arch. For an established family the smoke must exit 0. A per-family real-model smoke (`tests/_real_smoke.cpp`) is a Stage 4 artifact, not a Stage 3 gate. +Quant-policy sync: the converter-side dtype bucketing (`reference_dtype_for`) must +stay aligned with the canonical `policy.cpp::classify_tensor` and must not have +regressed. Fast, no model files: + +```bash +uv run scripts/lib/test_quant_policy_sync.py +``` + ### Step 6: Sign-off Report: @@ -141,6 +177,7 @@ Report: - `models//-.gguf` exists. - `reports/convert/-.json` exists and records the SHA + source revision. - Preflight Gate B is green. +- `scripts/lib/test_quant_policy_sync.py` exits 0 (converter-side dtype bucketing in sync with `policy.cpp`). - The full quant matrix is NOT generated here — that is Stage 5 (`porting-5-quants`). ## Pointers (read, not execute) @@ -151,5 +188,6 @@ Report: - `scripts/convert-parakeet.py` (NeMo `.nemo`) - `scripts/convert-cohere.py` (Transformers) - `scripts/convert-qwen3_asr.py` (author-repo) -- `scripts/lib/gguf_common.py` — shared KV-write helpers (execute only indirectly, via the converter) +- `scripts/lib/gguf_common.py` — shared KV-write helpers + `reference_dtype_for` bucketing (execute only indirectly, via the converter) +- `scripts/lib/test_quant_policy_sync.py` — pins `reference_dtype_for` against `policy.cpp`; the Step 5 quant-policy gate, and where you register a new family's norm/conv tensor names - `src/arch//weights.cpp` for any already-ported family — the loader's authoritative tensor-name and shape expectations diff --git a/.github/workflows/python-bindings.yml b/.github/workflows/python-bindings.yml index 23b90e89..2a827eff 100644 --- a/.github/workflows/python-bindings.yml +++ b/.github/workflows/python-bindings.yml @@ -25,6 +25,7 @@ on: - "bindings/python/**" - "include/**" - "src/**" + - "scripts/lib/**" - "tests/**" - "ggml/**" - "CMakeLists.txt" @@ -36,6 +37,7 @@ on: - "bindings/python/**" - "include/**" - "src/**" + - "scripts/lib/**" - "tests/**" - "ggml/**" - "CMakeLists.txt" @@ -76,6 +78,8 @@ jobs: bindings/python/_generate/generate.py --check - name: Version sync (header / pyproject / __init__) run: uv run --no-project bindings/python/_generate/check_version_sync.py + - name: GGUF writer layout + run: uv run scripts/lib/test_gguf_writer.py python-shared: runs-on: blacksmith-2vcpu-ubuntu-2404 diff --git a/bindings/python/src/transcribe_cpp/__init__.py b/bindings/python/src/transcribe_cpp/__init__.py index 6aafe787..eadfc1b1 100644 --- a/bindings/python/src/transcribe_cpp/__init__.py +++ b/bindings/python/src/transcribe_cpp/__init__.py @@ -474,6 +474,7 @@ class Capabilities: supports_streaming: bool supports_spec_decode: bool max_audio_ms: int + translate_target_languages: tuple[str, ...] @dataclass(frozen=True) @@ -847,6 +848,10 @@ def capabilities(self) -> Capabilities: if caps.languages and caps.n_languages > 0: for i in range(caps.n_languages): languages.append(_decode(caps.languages[i])) + translate_targets = [] + if caps.translate_target_languages and caps.n_translate_target_languages > 0: + for i in range(caps.n_translate_target_languages): + translate_targets.append(_decode(caps.translate_target_languages[i])) return Capabilities( native_sample_rate=caps.native_sample_rate, languages=tuple(languages), @@ -856,6 +861,7 @@ def capabilities(self) -> Capabilities: supports_streaming=bool(caps.supports_streaming), supports_spec_decode=bool(caps.supports_spec_decode), max_audio_ms=caps.max_audio_ms, + translate_target_languages=tuple(translate_targets), ) def supports(self, feature: Feature) -> bool: diff --git a/bindings/python/src/transcribe_cpp/_generated.py b/bindings/python/src/transcribe_cpp/_generated.py index 2c84289d..3862bd79 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 = "ebe6a6816e34a24e" +PUBLIC_HEADER_HASH = "86b16dd97ad1cb58" # === enum constants === TRANSCRIBE_OK = 0 @@ -153,7 +153,7 @@ class transcribe_whisper_chunk_trace(_c.Structure): 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), ("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_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)] +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)] transcribe_stream_update._fields_ = [("struct_size", _c.c_uint64), ("result_changed", _c.c_bool), ("is_final", _c.c_bool), ("revision", _c.c_int32), ("input_received_ms", _c.c_int64), ("audio_committed_ms", _c.c_int64), ("buffered_ms", _c.c_int64), ("committed_changed", _c.c_bool), ("tentative_changed", _c.c_bool)] @@ -195,7 +195,7 @@ class transcribe_whisper_chunk_trace(_c.Structure): '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': 64, 'align': 8, 'offsets': {'struct_size': 0, 'task': 8, 'timestamps': 12, 'pnc': 16, 'itn': 20, 'language': 24, 'target_language': 32, 'keep_special_tags': 40, 'family': 48, 'spec_k_drafts': 56}}, - 'transcribe_capabilities': {'size': 40, '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}}, + '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}}, 'transcribe_stream_update': {'size': 48, 'align': 8, 'offsets': {'struct_size': 0, 'result_changed': 8, 'is_final': 9, 'revision': 12, 'input_received_ms': 16, 'audio_committed_ms': 24, 'buffered_ms': 32, 'committed_changed': 40, 'tentative_changed': 41}}, @@ -297,6 +297,8 @@ def configure(lib): lib.transcribe_model_load_file.argtypes = [_c.c_char_p, _c.POINTER(transcribe_model_load_params), _c.POINTER(_c.c_void_p)] lib.transcribe_model_load_params_init.restype = None lib.transcribe_model_load_params_init.argtypes = [_c.POINTER(transcribe_model_load_params)] + lib.transcribe_model_meta_val_str.restype = _c.c_char_p + lib.transcribe_model_meta_val_str.argtypes = [_c.c_void_p, _c.c_char_p] lib.transcribe_model_supports.restype = _c.c_bool lib.transcribe_model_supports.argtypes = [_c.c_void_p, _c.c_int] lib.transcribe_model_variant_string.restype = _c.c_char_p diff --git a/bindings/rust/sys/src/transcribe_sys.rs b/bindings/rust/sys/src/transcribe_sys.rs index f2aea5a2..d5a353db 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 = ebe6a6816e34a24e +// Pinned to include/transcribe.abihash = 86b16dd97ad1cb58 /// 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 = "ebe6a6816e34a24e"; +pub const PUBLIC_HEADER_HASH: &str = "86b16dd97ad1cb58"; /* automatically generated by rust-bindgen 0.72.1 */ @@ -372,10 +372,12 @@ pub struct transcribe_capabilities { pub supports_streaming: bool, pub supports_spec_decode: bool, pub max_audio_ms: i64, + pub n_translate_target_languages: ::std::os::raw::c_int, + pub translate_target_languages: *const *const ::std::os::raw::c_char, } #[allow(clippy::unnecessary_operation, clippy::identity_op)] const _: () = { - ["Size of transcribe_capabilities"][::std::mem::size_of::() - 40usize]; + ["Size of transcribe_capabilities"][::std::mem::size_of::() - 56usize]; ["Alignment of transcribe_capabilities"] [::std::mem::align_of::() - 8usize]; ["Offset of field: transcribe_capabilities::struct_size"] @@ -398,6 +400,10 @@ const _: () = { [::std::mem::offset_of!(transcribe_capabilities, supports_spec_decode) - 31usize]; ["Offset of field: transcribe_capabilities::max_audio_ms"] [::std::mem::offset_of!(transcribe_capabilities, max_audio_ms) - 32usize]; + ["Offset of field: transcribe_capabilities::n_translate_target_languages"] + [::std::mem::offset_of!(transcribe_capabilities, n_translate_target_languages) - 40usize]; + ["Offset of field: transcribe_capabilities::translate_target_languages"] + [::std::mem::offset_of!(transcribe_capabilities, translate_target_languages) - 48usize]; }; unsafe extern "C" { pub fn transcribe_capabilities_init(out: *mut transcribe_capabilities); @@ -440,6 +446,12 @@ unsafe extern "C" { model: *const transcribe_model, ) -> *const ::std::os::raw::c_char; } +unsafe extern "C" { + pub fn transcribe_model_meta_val_str( + model: *const transcribe_model, + key: *const ::std::os::raw::c_char, + ) -> *const ::std::os::raw::c_char; +} unsafe extern "C" { pub fn transcribe_model_load_file( path: *const ::std::os::raw::c_char, diff --git a/bindings/rust/transcribe-cpp/src/model.rs b/bindings/rust/transcribe-cpp/src/model.rs index 6dfb5a37..5a54260d 100644 --- a/bindings/rust/transcribe-cpp/src/model.rs +++ b/bindings/rust/transcribe-cpp/src/model.rs @@ -51,6 +51,8 @@ pub struct Capabilities { pub native_sample_rate: i32, /// Supported language codes (empty if the model is language-agnostic). pub languages: Vec, + /// Supported translation target language codes (empty if not advertised). + pub translate_target_languages: Vec, /// The finest timestamp granularity the model can produce. pub max_timestamp_kind: TimestampKind, pub supports_language_detect: bool, @@ -159,10 +161,23 @@ impl Model { languages.push(owned_str(lang)); } } + let mut translate_target_languages = Vec::new(); + if !caps.translate_target_languages.is_null() && caps.n_translate_target_languages > 0 { + let slice = unsafe { + std::slice::from_raw_parts( + caps.translate_target_languages, + caps.n_translate_target_languages as usize, + ) + }; + for &lang in slice { + translate_target_languages.push(owned_str(lang)); + } + } Capabilities { native_sample_rate: caps.native_sample_rate, languages, + translate_target_languages, max_timestamp_kind: TimestampKind::from_raw(caps.max_timestamp_kind), supports_language_detect: caps.supports_language_detect, supports_translate: caps.supports_translate, diff --git a/bindings/swift/Sources/TranscribeCpp/ABIHash.swift b/bindings/swift/Sources/TranscribeCpp/ABIHash.swift index 346391eb..0ad79fb9 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 = "ebe6a6816e34a24e" + public static let pinnedHeaderHash = "86b16dd97ad1cb58" /// 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/Transcript.swift b/bindings/swift/Sources/TranscribeCpp/Transcript.swift index 70a6622b..3431cb06 100644 --- a/bindings/swift/Sources/TranscribeCpp/Transcript.swift +++ b/bindings/swift/Sources/TranscribeCpp/Transcript.swift @@ -88,6 +88,8 @@ public struct Capabilities: Sendable, Equatable { public let nativeSampleRate: Int32 /// Supported language codes; empty when the model is language-agnostic. public let languages: [String] + /// Supported translation target language codes; empty when not advertised. + public let translateTargetLanguages: [String] public let maxTimestampKind: TimestampKind public let supportsLanguageDetect: Bool public let supportsTranslate: Bool @@ -105,6 +107,13 @@ public struct Capabilities: Sendable, Equatable { } } languages = langs + var targetLangs: [String] = [] + if let arr = c.translate_target_languages { + for i in 0.. = { '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: 64, align: 8, offsets: {'struct_size': 0, 'task': 8, 'timestamps': 12, 'pnc': 16, 'itn': 20, 'language': 24, 'target_language': 32, 'keep_special_tags': 40, 'family': 48, 'spec_k_drafts': 56} }, - 'transcribe_capabilities': { size: 40, 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} }, + '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} }, 'transcribe_stream_update': { size: 48, align: 8, offsets: {'struct_size': 0, 'result_changed': 8, 'is_final': 9, 'revision': 12, 'input_received_ms': 16, 'audio_committed_ms': 24, 'buffered_ms': 32, 'committed_changed': 40, 'tentative_changed': 41} }, @@ -153,7 +153,7 @@ export function defineTypes(koffi: any): Record { 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', language: 'char *', target_language: 'char *', keep_special_tags: 'bool', family: 'void *', spec_k_drafts: 'int32_t' }); - 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' }); + 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' }); T['transcribe_stream_update'] = koffi.struct({ struct_size: 'uint64_t', result_changed: 'bool', is_final: 'bool', revision: 'int32_t', input_received_ms: 'int64_t', audio_committed_ms: 'int64_t', buffered_ms: 'int64_t', committed_changed: 'bool', tentative_changed: 'bool' }); @@ -214,6 +214,7 @@ export const FUNCTION_SIGNATURES: Record = { 'transcribe_model_get_device': { ret: 'transcribe_status', args: ['const struct transcribe_model *', 'struct transcribe_backend_device *'] }, 'transcribe_model_load_file': { ret: 'transcribe_status', args: ['const char *', 'const struct transcribe_model_load_params *', 'struct transcribe_model **'] }, 'transcribe_model_load_params_init': { ret: 'void', args: ['struct transcribe_model_load_params *'] }, + 'transcribe_model_meta_val_str': { ret: 'const char *', args: ['const struct transcribe_model *', 'const char *'] }, 'transcribe_model_supports': { ret: '_Bool', args: ['const struct transcribe_model *', 'transcribe_feature'] }, 'transcribe_model_variant_string': { ret: 'const char *', args: ['const struct transcribe_model *'] }, 'transcribe_moonshine_streaming_stream_ext_init': { ret: 'void', args: ['struct transcribe_moonshine_streaming_stream_ext *'] }, diff --git a/bindings/typescript/src/index.ts b/bindings/typescript/src/index.ts index 5ef90e29..d87440f2 100644 --- a/bindings/typescript/src/index.ts +++ b/bindings/typescript/src/index.ts @@ -1276,6 +1276,7 @@ export class TranscribeModel { n.F.capabilitiesInit(c); check(n, n.F.modelGetCapabilities(this.handle, c), "reading capabilities"); let languages: string[] = []; + let translateTargetLanguages: string[] = []; try { if (c.languages && c.n_languages > 0) { languages = n.koffi.decode(c.languages, "char *", c.n_languages); @@ -1283,9 +1284,21 @@ export class TranscribeModel { } catch { languages = []; } + try { + if (c.translate_target_languages && c.n_translate_target_languages > 0) { + translateTargetLanguages = n.koffi.decode( + c.translate_target_languages, + "char *", + c.n_translate_target_languages, + ); + } + } catch { + translateTargetLanguages = []; + } return { nativeSampleRate: c.native_sample_rate, languages, + translateTargetLanguages, maxTimestampKind: TIMESTAMP_NAMES[c.max_timestamp_kind] ?? "none", supportsLanguageDetect: c.supports_language_detect, supportsTranslate: c.supports_translate, diff --git a/bindings/typescript/src/types.ts b/bindings/typescript/src/types.ts index 81312120..12d65131 100644 --- a/bindings/typescript/src/types.ts +++ b/bindings/typescript/src/types.ts @@ -56,6 +56,7 @@ export interface Timings { export interface Capabilities { nativeSampleRate: number; languages: string[]; + translateTargetLanguages: string[]; maxTimestampKind: TimestampKind; supportsLanguageDetect: boolean; supportsTranslate: boolean; diff --git a/docs/models/granite-4.0-1b-speech.md b/docs/models/granite-4.0-1b-speech.md index 13e25413..7f415dbc 100644 --- a/docs/models/granite-4.0-1b-speech.md +++ b/docs/models/granite-4.0-1b-speech.md @@ -12,10 +12,10 @@ Spanish, Portuguese, and Japanese. The model takes a 16 kHz mono WAV and produces a transcript. Translation pairs: English ↔ French, English ↔ German, English ↔ Spanish, -English ↔ Portuguese, English ↔ Japanese. Always via English — there is no -direct fr↔de, fr↔es, etc. Pass the target language as a BCP-47 code via -`--translate --target-language `; the source language is inferred -from the audio. +English ↔ Portuguese, English ↔ Japanese, plus English-to-Italian and +English-to-Mandarin. Always via English — there is no direct fr↔de, fr↔es, +etc. Pass the target language as a BCP-47 code via `--translate +--target-language `; the source language is inferred from the audio. See IBM's [model card](https://huggingface.co/ibm-granite/granite-4.0-1b-speech) for training data, intended use, and upstream evaluation methodology. @@ -129,7 +129,7 @@ Linux 6.18 (Fedora 43), transcribe.cpp `dbe5814`. |-----------------------------|--------| | Transcribe (English) | Yes | | Transcribe (fr/de/es/pt/ja) | Yes | -| Translate (X→En, En→X) | Yes (`--translate --target-language `) | +| 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) | diff --git a/docs/models/granite-speech-4.1-2b-plus.md b/docs/models/granite-speech-4.1-2b-plus.md index 859b643a..8d725812 100644 --- a/docs/models/granite-speech-4.1-2b-plus.md +++ b/docs/models/granite-speech-4.1-2b-plus.md @@ -16,11 +16,9 @@ variant). Takes a 16 kHz mono WAV and produces a transcript, optionally interleaved with `[SS:N]` word-timestamp markers (centiseconds since segment start). -Translation pairs: English ↔ French, English ↔ German, English ↔ Spanish, -English ↔ Portuguese. Always via English — there is no direct fr↔de, -fr↔es, etc. Pass the target language as a BCP-47 code via -`--translate --target-language `; the source language is inferred -from the audio. +This variant is transcription-only. Unlike the base +[`granite-speech-4.1-2b`](granite-speech-4.1-2b.md), it does not perform +speech translation. See IBM's [model card](https://huggingface.co/ibm-granite/granite-speech-4.1-2b-plus) for training data, intended use, and upstream evaluation methodology. @@ -83,15 +81,6 @@ interleaved with words: [SS:23] And [SS:52] so [SS:92] my [SS:121] fellow [SS:154] Americans ... ``` -Translation: - -```bash -build/bin/transcribe-cli \ - -m models/granite-speech-4.1-2b-plus/granite-speech-4.1-2b-plus-Q8_0.gguf \ - --translate --target-language de \ - samples/jfk.wav -``` - ## Performance Cells are wall-clock latency, with speedup over realtime in parentheses. @@ -153,7 +142,7 @@ Linux 6.18 (Fedora 43), transcribe.cpp `dbe5814`. |-----------------------------|--------| | Transcribe (English) | Yes | | Transcribe (fr/de/es/pt) | Yes | -| Translate (En→X) | Yes (`--translate --target-language `) | +| 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`, `[SS:N]` markers) | | Speaker diarization | No (upstream supports via prompt; not exposed in v1 of transcribe.cpp) | diff --git a/docs/models/granite-speech-4.1-2b.md b/docs/models/granite-speech-4.1-2b.md index f2ede9e7..d0fd3b9c 100644 --- a/docs/models/granite-speech-4.1-2b.md +++ b/docs/models/granite-speech-4.1-2b.md @@ -13,10 +13,10 @@ Spanish, Portuguese, and Japanese. Takes a 16 kHz mono WAV and produces a transcript. Translation pairs: English ↔ French, English ↔ German, English ↔ Spanish, -English ↔ Portuguese, English ↔ Japanese. Always via English — there is no -direct fr↔de, fr↔es, etc. Pass the target language as a BCP-47 code via -`--translate --target-language `; the source language is inferred -from the audio. +English ↔ Portuguese, English ↔ Japanese, plus English-to-Italian and +English-to-Mandarin. Always via English — there is no direct fr↔de, fr↔es, +etc. Pass the target language as a BCP-47 code via `--translate +--target-language `; the source language is inferred from the audio. See IBM's [model card](https://huggingface.co/ibm-granite/granite-speech-4.1-2b) for training data, intended use, and upstream evaluation methodology. @@ -130,7 +130,7 @@ Linux 6.18 (Fedora 43), transcribe.cpp `dbe5814`. |-----------------------------|--------| | Transcribe (English) | Yes | | Transcribe (fr/de/es/pt/ja) | Yes | -| Translate (X→En, En→X) | Yes (`--translate --target-language `) | +| 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) | diff --git a/docs/models/granite-speech.md b/docs/models/granite-speech.md index 13cbe8dc..2e95788c 100644 --- a/docs/models/granite-speech.md +++ b/docs/models/granite-speech.md @@ -44,9 +44,9 @@ quant matrix. | Variant | Decode mode | Params | Q8_0 size | WER (Q8_0) | Languages | Extras | Doc | | --- | --- | ---: | ---: | ---: | --- | --- | --- | -| `granite-4.0-1b-speech` | AR (audio-LLM) | ~3B† | 2.56 GB | 1.44% | en, fr, de, es, pt, ja | translate (en ↔ each) | [granite-4.0-1b-speech.md](granite-4.0-1b-speech.md) | -| `granite-speech-4.1-2b` | AR (audio-LLM) | ~3B† | 2.56 GB | 1.32% | en, fr, de, es, pt, ja | translate (en ↔ each) | [granite-speech-4.1-2b.md](granite-speech-4.1-2b.md) | -| `granite-speech-4.1-2b-plus` | AR (audio-LLM) | ~3B† | 2.35 GB | 1.50% | en, fr, de, es, pt | translate (en ↔ each), word timestamps | [granite-speech-4.1-2b-plus.md](granite-speech-4.1-2b-plus.md) | +| `granite-4.0-1b-speech` | AR (audio-LLM) | ~3B† | 2.56 GB | 1.44% | en, fr, de, es, pt, ja | translate (en ↔ ASR langs; en → it/zh) | [granite-4.0-1b-speech.md](granite-4.0-1b-speech.md) | +| `granite-speech-4.1-2b` | AR (audio-LLM) | ~3B† | 2.56 GB | 1.32% | en, fr, de, es, pt, ja | translate (en ↔ ASR langs; en → it/zh) | [granite-speech-4.1-2b.md](granite-speech-4.1-2b.md) | +| `granite-speech-4.1-2b-plus` | AR (audio-LLM) | ~3B† | 2.35 GB | 1.50% | en, fr, de, es, pt | word timestamps (ASR only) | [granite-speech-4.1-2b-plus.md](granite-speech-4.1-2b-plus.md) | | `granite-speech-4.1-2b-nar` | NAR (editor) | ~3B† | 2.33 GB | 1.29% | en, fr, de, es, pt | (ASR only) | [granite-speech-4.1-2b-nar.md](granite-speech-4.1-2b-nar.md) | † Parameter counts include the Conformer audio encoder, the projector, @@ -92,14 +92,13 @@ All variants: - **Transcription** of 16 kHz mono WAV input across the variant's supported languages. -AR variants (`granite-4.0-1b-speech`, `granite-speech-4.1-2b`, -`granite-speech-4.1-2b-plus`): -- **Translation** between English and each of the variant's other - languages, in either direction (en ↔ fr, en ↔ de, en ↔ es, en ↔ pt, - and en ↔ ja for 4.0-1b and 4.1-2b). There is no direct fr↔de etc. — - translation always involves English on one side. Use - `--translate --target-language `; the source language is - inferred from the audio. +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 + and English-to-Mandarin (`--target-language it` / `zh`). There is no direct + fr↔de etc. — translation always involves English on one side. Use + `--translate --target-language `; the source language is inferred + from the audio. The `-plus` variant is ASR-only and does not translate. Plus only (`granite-speech-4.1-2b-plus`): - **Word-level timestamps** as `[SS:N]` centisecond markers interleaved diff --git a/docs/models/moonshine-streaming-medium.md b/docs/models/moonshine-streaming-medium.md index 8599b1db..53f2cf1a 100644 --- a/docs/models/moonshine-streaming-medium.md +++ b/docs/models/moonshine-streaming-medium.md @@ -38,6 +38,23 @@ variants (where the tiny cross-check against the HF Transformers reference on the same manifest landed within 0.01pp of our port), and is not a numerical drift in the port. +**One utterance the model cannot end.** A single LibriSpeech test-clean clip — +`7176-92135-0020` (7.2 s; reference *"DOUBLE NINE TWO THREE ELSINORE DOUBLE NINE +YES HALLO IS THAT YOU HORATIO HAMLET SPEAKING"*) — drives the **medium** model +into a hallucination loop: it collapses the repeated digits into an endless run +of a single token and never emits end-of-stream. This is a property of the +upstream weights, **not the port** — the HF Transformers reference does the +identical thing (with `max_new_tokens` unbounded it emits an uninterrupted +stream of `9`s and never stops). The tiny and small variants terminate normally +on this clip; only medium loops. Our decoder bounds the runaway with a +duration-based generation budget (≈6.5 tokens per second of audio, plus a small +floor — matching the model card's recommended `max_new_tokens ≈ audio_seconds × +6.5`), so the loop stops after a few dozen tokens instead of grinding to the +decoder's position cap. The utterance is flagged via +`transcribe_was_truncated()` and surfaced as an `output truncated` error +(non-zero exit); its incomplete transcript is counted as a miss in the 2.16% +WER above, so the baseline already includes this one pathological utterance. + Q6_K / Q5_K_M / Q4_K_M GGUFs are not currently shipped for this variant. ## Quick Start diff --git a/docs/porting/1a-intake.md b/docs/porting/1a-intake.md index 13493e21..39495387 100644 --- a/docs/porting/1a-intake.md +++ b/docs/porting/1a-intake.md @@ -170,9 +170,10 @@ Mirrored into the golden manifest; cross-checked by preflight against GGUF | `capabilities.languages` | script + human | BCP-47 codes. Script auto-extracts from common config fields (`languages`, `supported_languages`, `language_list`, `text_config.languages`). Falls back to human-fill from the model card | | `capabilities.language_detection` | human | Auto-detects input language without a hint (Whisper-style `<|detect|>` tokens, Qwen3-ASR's detection branch) | | `capabilities.translation` | human | Produces output in a different language than the input audio. Most transducers don't; encoder-decoder + audio-LLM often do | +| `capabilities.translation_target_languages` | human | Output language codes accepted for translation. Leave empty or omit when translation is false or unknown | +| `capabilities.translation_pairs` | human | Allowed directions as `src>target`, only when support is not the simple source-language x target-language cross product | | `capabilities.timestamps` | human | Subset of `["none", "segment", "word", "token"]`. Parakeet has word+token, Whisper has segment optional word | | `capabilities.streaming` | human | Streaming / chunked real-time capable | -| `capabilities.voice_activity_detection` | human | Model emits VAD decisions as part of output | | `capabilities.speaker_diarization` | human | Multi-speaker attribution | Any of the boolean flags may be `null` if unknown — a gap the maintainer diff --git a/docs/porting/2-artifacts-and-goldens.md b/docs/porting/2-artifacts-and-goldens.md index 1bdf4d4b..24338fd1 100644 --- a/docs/porting/2-artifacts-and-goldens.md +++ b/docs/porting/2-artifacts-and-goldens.md @@ -109,7 +109,6 @@ the model card, not a separate artifact. "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/cohere.json", @@ -146,10 +145,12 @@ the model card, not a separate artifact. - `tokenizer_summary`: tokenizer type, vocab size, special token IDs. Not the full tokenizer — that lives in the GGUF. - `capabilities`: what the family claims to support (languages, - translation, timestamps, streaming, VAD, diarization). Mirrors intake. - Preflight cross-checks `capabilities.languages` against the GGUF's - `general.languages` array and `capabilities.language_detection` against - `stt.capability.lang_detect`. + translation, translation targets/pairs, timestamps, streaming, VAD, + diarization). Mirrors intake. Preflight cross-checks + `capabilities.languages` against the GGUF's `general.languages` array, + `capabilities.language_detection` against `stt.capability.lang_detect`, + `capabilities.translation` against `stt.capability.translate`, and + translation target/pair declarations against `stt.translation.*` KVs. - `tolerance_file`: relative path to the family's tolerance file. - `cases`: sample basenames under `samples/`, such as `jfk`. diff --git a/docs/porting/3-conversion.md b/docs/porting/3-conversion.md index 2c39d94b..7be0dcb7 100644 --- a/docs/porting/3-conversion.md +++ b/docs/porting/3-conversion.md @@ -83,12 +83,20 @@ hparams and tensor catalog mapping. Shared logic lives in `scripts/lib/` (plain importable module, not an installable package — each per-family `uv` env adds `scripts/lib/` to `sys.path`): -- `gguf_common.py` — GGUF metadata writer helpers, tensor name - canonicalization, fp32/f16/bf16 `encode_for_gguf()`, manifest writer, - file hashing. +- `gguf_common.py` — GGUF identity/KV helpers (`add_general_identity`), + output-name derivation (`slug_from_repo_id`, `gguf_name`), + reference-dtype routing + fp32/f16/bf16 `encode_for_gguf()`, + special-token id helper (`safe_id`), and frontend-normalize + canonicalization (`canonicalize_normalize`). - `quant_policy.py` — preset name registry (names only; quantization math lives in `transcribe-quantize`). +Manifest writing, file hashing, HF snapshot resolution, sharded +safetensors reading, and tensor-name canonicalization are **currently +duplicated per-converter**, not shared — candidates for extraction into +`scripts/lib/`. The "Converter Manifest" section above describes the +target contract, not a shared implementation that exists today. + Family-specific converter code remains responsible for: - reading the upstream config diff --git a/docs/porting/families/_intake-schema.json b/docs/porting/families/_intake-schema.json index fedab693..b0fe5da5 100644 --- a/docs/porting/families/_intake-schema.json +++ b/docs/porting/families/_intake-schema.json @@ -217,6 +217,16 @@ "type": ["boolean", "null"], "description": "Model produces output in a different language than the input audio." }, + "translation_target_languages": { + "type": "array", + "items": {"type": "string"}, + "description": "Output language codes accepted for translation. Omit or leave empty when translation is false or unknown." + }, + "translation_pairs": { + "type": "array", + "items": {"type": "string", "pattern": "^[^>]+>[^>]+$"}, + "description": "Allowed translation directions as src>target. Only needed when support is not a simple source-language x target-language cross product." + }, "timestamps": { "type": "array", "items": {"type": "string", "enum": ["none", "segment", "word", "token"]}, @@ -226,7 +236,6 @@ "type": ["boolean", "null"], "description": "Model supports streaming / chunked real-time inference." }, - "voice_activity_detection": {"type": ["boolean", "null"]}, "speaker_diarization": {"type": ["boolean", "null"]} } }, diff --git a/docs/porting/families/granite.md b/docs/porting/families/granite.md index 99a3d4d7..88e766a2 100644 --- a/docs/porting/families/granite.md +++ b/docs/porting/families/granite.md @@ -154,7 +154,7 @@ Allowed statuses: `PASS`, `SKIP - not exposed by runtime`, `ACCEPTED GAP - X) | granite-speech-4.1-2b-plus | English source audio + target language hint | `build/bin/transcribe-cli -m models/granite-speech-4.1-2b-plus/granite-speech-4.1-2b-plus-BF16.gguf --translate --target-language de samples/jfk.wav` | non-empty non-English transcript | PASS ("Ich bitte meine Mitbürger, fragen Sie sich nicht ...") | +| Translate | granite-speech-4.1-2b-plus | n/a | n/a | runtime rejects `--translate` (`supports_translate=false`) | SKIP - not exposed by runtime (upstream model card lists -plus as ASR-only; the base granite-speech-4.1-2b is the AST variant. The fused LLM can be prompt-coerced to translate — the smoke produced "Ich bitte meine Mitbürger, ..." — but the converter advertises `stt.capability.translate=false`, so the dispatcher rejects the task.) | | Word timestamps | granite-speech-4.1-2b-plus | text-protocol output | `build/bin/transcribe-cli -m models/granite-speech-4.1-2b-plus/granite-speech-4.1-2b-plus-BF16.gguf --timestamps word samples/jfk.wav` | `[T:N]` centisecond markers interleaved with the transcript text | PASS (model emits `[SS:N]` markers: e.g. "[SS:23] And [SS:52] so [SS:92] my [SS:121] fellow [SS:154] Americans ..."). Spec says `[T:N]`; granite uses `[SS:N]` literally. | | Speaker diarization | granite-speech-4.1-2b-plus | multi-speaker audio | n/a | `[Speaker N]:` tags preceding speaker turns in the text | SKIP - not exposed by runtime (user-deferred to later) | | Incremental decoding | granite-speech-4.1-2b-plus | prefix_text continuation | n/a | transcript continues from supplied prefix | SKIP - not exposed by runtime | diff --git a/docs/porting/families/granite_nar.md b/docs/porting/families/granite_nar.md index 0d1871bb..082fa361 100644 --- a/docs/porting/families/granite_nar.md +++ b/docs/porting/families/granite_nar.md @@ -6,7 +6,7 @@ Status: research - Family key: `granite_nar` - Upstream architecture string: `nle` (`NLENARDecoder`) -- Hugging Face repo: `ibm-granite/granite-speech-4.1-2b-nar` (pinned `7d20732df04d097262c4ecd8fe7f34ec2b3e6c42`) +- Hugging Face repo: `ibm-granite/granite-speech-4.1-2b-nar` (pinned `99a4df9007ac5682f9daa093fb7008ff606e9a5d`) - License: Apache-2.0 - Variants: - `granite-speech-4.1-2b-nar`: Non-autoregressive editor; en/fr/de/es/pt transcription only. diff --git a/docs/tools/conversion.md b/docs/tools/conversion.md index 45402d00..fd953c29 100644 --- a/docs/tools/conversion.md +++ b/docs/tools/conversion.md @@ -28,10 +28,17 @@ What conversion does **not** do: ## Current families -| Family | Script | Env | Source format | -|----------|---------------------------------|------------------------------|------------------------------| -| parakeet | `scripts/convert-parakeet.py` | `scripts/envs/parakeet/` | NeMo `.nemo` archive (via `ASRModel.from_pretrained`) | -| cohere | `scripts/convert-cohere.py` | `scripts/envs/cohere/` | HuggingFace safetensors (bf16) | +The authoritative list is the set of `scripts/convert-*.py` scripts; each +family also has a `uv` env at `scripts/envs//`. Grouped by upstream +source format: + +- **NeMo `.nemo`** (via `ASRModel.from_pretrained`): `parakeet`, `canary`, + `canary_qwen` +- **HuggingFace safetensors / Transformers**: `whisper`, `voxtral`, + `voxtral_realtime`, `granite`, `granite_nar`, `moonshine`, + `moonshine_streaming`, `qwen3_asr`, `medasr`, `cohere` +- **FunASR**: `sensevoice`, `funasr_nano` +- **Author package**: `gigaam` (the upstream `gigaam` pip package) Each converter is a single-file script with inline documentation of its tensor catalog, hparam map, and layout transforms. No base class. See @@ -124,7 +131,7 @@ component bucket rules. point. Both are deliberately readable top-to-bottom. 3. Write the hparam map, tensor catalog, and layout transforms inline. 4. Import shared helpers from `scripts/lib/` (GGUF KV helpers, - fp encoding, manifest writing). **Do not** import a per-family + fp encoding). **Do not** import a per-family base class — there isn't one, and there shouldn't be one until we have 5+ families of the same shape. 5. Update the C++ loader (`src/arch//weights.cpp`) to accept @@ -136,11 +143,16 @@ component bucket rules. `scripts/lib/` holds code that every converter uses but that doesn't justify a class hierarchy: -- `gguf_common.py` — KV writer helpers, tensor name canonicalization, - fp32/f16/bf16 `encode_for_gguf()`. +- `gguf_common.py` — GGUF identity/KV helpers, output-name derivation, + reference-dtype routing + fp32/f16/bf16 `encode_for_gguf()`, and + frontend-normalize canonicalization. - `quant_policy.py` — preset name registry (names only, no math; quantization math lives in C++). +Manifest writing, file hashing, HF snapshot resolution, and sharded +safetensors reading are currently duplicated per-converter (candidates +for extraction into `scripts/lib/`), not shared today. + Import with a two-line `sys.path.insert` at the top of each converter. This matches `llama.cpp`'s `gguf-py` pattern: a local importable module, not an installable package. diff --git a/examples/cli/main.cpp b/examples/cli/main.cpp index f521249b..a869a07d 100644 --- a/examples/cli/main.cpp +++ b/examples/cli/main.cpp @@ -971,6 +971,12 @@ int main(int argc, char ** argv) { return EXIT_FAILURE; } std::printf(" backend: %s\n", transcribe_model_backend(model)); + if (const char * dn = transcribe_model_meta_val_str(model, "general.name"); dn[0]) { + std::printf(" name: %s\n", dn); + } + if (const char * lic = transcribe_model_meta_val_str(model, "general.license"); lic[0]) { + std::printf(" license: %s\n", lic); + } struct transcribe_session_params cp; transcribe_session_params_init(&cp); cp.n_threads = args.n_threads; diff --git a/include/transcribe.abihash b/include/transcribe.abihash index 3d27bb16..dc4df617 100644 --- a/include/transcribe.abihash +++ b/include/transcribe.abihash @@ -1 +1 @@ -ebe6a6816e34a24e +86b16dd97ad1cb58 diff --git a/include/transcribe.h b/include/transcribe.h index c8f7024e..21d02130 100644 --- a/include/transcribe.h +++ b/include/transcribe.h @@ -1207,6 +1207,26 @@ struct transcribe_capabilities { * docs/input-limits.md for the full contract. */ int64_t max_audio_ms; + + /* + * translate_target_languages / n_translate_target_languages: the set + * of target language codes accepted for TRANSCRIBE_TASK_TRANSLATE — + * the target-side twin of `languages` (which is the valid set for the + * transcribe-side `language` hint). supports_translate gates whether + * translation runs at all; this list narrows WHICH targets are valid. + * A TRANSLATE run whose run_params::target_language is non-NULL and + * absent from a non-empty list is rejected with + * TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE before any decode. + * + * n == 0 / NULL is "not advertised" — an information gap, not a claim + * of zero targets — exactly the convention an empty `languages` uses. + * GGUFs predating stt.translation.target_languages report 0 here even + * when supports_translate is true; the gate is then inert and any + * family-level target/pair checks (e.g. canary's pivot pairs) still + * apply on top. + */ + int n_translate_target_languages; + const char * const * translate_target_languages; }; TRANSCRIBE_API void transcribe_capabilities_init( @@ -1322,6 +1342,27 @@ TRANSCRIBE_API const char * transcribe_model_arch_string(const struct transcribe TRANSCRIBE_API const char * transcribe_model_variant_string(const struct transcribe_model * model); TRANSCRIBE_API const char * transcribe_model_backend(const struct transcribe_model * model); +/* + * Generic GGUF string-metadata getter, modeled on llama_model_meta_val_str. + * Looks up a scalar-string metadata key written by the converter and returns + * its value; this is how human-facing identity is read rather than a typed + * accessor per field. Common keys: + * + * "general.name" friendly label, e.g. "Whisper Large v3" + * "general.license" SPDX expression, e.g. "apache-2.0" (or "other") + * "general.license.name" human-friendly license name + * "general.license.link" URL to the license text + * "general.author", "general.organization", "general.repo_url", ... + * + * Returns a model-owned string (valid until the model is freed; do not free + * it) or an empty string "" when model is NULL, key is NULL, or the key is + * absent. Only scalar-string KVs are exposed (numeric hyperparameters and + * arrays such as the token list are not). There is no fallback to the variant + * slug — for that, use transcribe_model_variant_string(). + */ +TRANSCRIBE_API const char * transcribe_model_meta_val_str( + const struct transcribe_model * model, const char * key); + /* ----------------------------------------------------------------------- */ /* Lifecycle */ /* ----------------------------------------------------------------------- */ diff --git a/reports/porting/canary/canary-180m-flash/intake.json b/reports/porting/canary/canary-180m-flash/intake.json index ac477810..0afa2e75 100644 --- a/reports/porting/canary/canary-180m-flash/intake.json +++ b/reports/porting/canary/canary-180m-flash/intake.json @@ -125,7 +125,6 @@ "translation": true, "timestamps": ["word", "segment"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/canary/canary-1b-flash/intake.json b/reports/porting/canary/canary-1b-flash/intake.json index 6e9f9465..99f4a9e4 100644 --- a/reports/porting/canary/canary-1b-flash/intake.json +++ b/reports/porting/canary/canary-1b-flash/intake.json @@ -142,7 +142,6 @@ "translation": true, "timestamps": ["word", "segment"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/canary/canary-1b-v2/intake.json b/reports/porting/canary/canary-1b-v2/intake.json index 8cd9210f..d04bdcc0 100644 --- a/reports/porting/canary/canary-1b-v2/intake.json +++ b/reports/porting/canary/canary-1b-v2/intake.json @@ -144,7 +144,6 @@ "translation": true, "timestamps": ["word", "segment"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/canary/canary-1b/intake.json b/reports/porting/canary/canary-1b/intake.json index 517dce00..44427cbf 100644 --- a/reports/porting/canary/canary-1b/intake.json +++ b/reports/porting/canary/canary-1b/intake.json @@ -129,7 +129,6 @@ "translation": true, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/canary_qwen/canary-qwen-2.5b/intake.json b/reports/porting/canary_qwen/canary-qwen-2.5b/intake.json index 9de4c1c7..30356352 100644 --- a/reports/porting/canary_qwen/canary-qwen-2.5b/intake.json +++ b/reports/porting/canary_qwen/canary-qwen-2.5b/intake.json @@ -167,7 +167,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/cohere/cohere-transcribe-03-2026/intake.json b/reports/porting/cohere/cohere-transcribe-03-2026/intake.json index 566e1b50..110b0871 100644 --- a/reports/porting/cohere/cohere-transcribe-03-2026/intake.json +++ b/reports/porting/cohere/cohere-transcribe-03-2026/intake.json @@ -140,7 +140,6 @@ "translation": false, "timestamps": ["none"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [], diff --git a/reports/porting/funasr_nano/fun-asr-mlt-nano-2512/intake.json b/reports/porting/funasr_nano/fun-asr-mlt-nano-2512/intake.json index 3d34692a..557110a2 100644 --- a/reports/porting/funasr_nano/fun-asr-mlt-nano-2512/intake.json +++ b/reports/porting/funasr_nano/fun-asr-mlt-nano-2512/intake.json @@ -163,7 +163,6 @@ "none" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [], diff --git a/reports/porting/funasr_nano/fun-asr-nano-2512/intake.json b/reports/porting/funasr_nano/fun-asr-nano-2512/intake.json index 362e7ddc..eed87572 100644 --- a/reports/porting/funasr_nano/fun-asr-nano-2512/intake.json +++ b/reports/porting/funasr_nano/fun-asr-nano-2512/intake.json @@ -159,7 +159,6 @@ "none" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ @@ -195,7 +194,7 @@ "Qwen3-0.6B chat template: language hint (`language=\"中文\"`) and hotwords are passed through `model.generate()`. The exact prompt template (system/user formatting + audio token placement) is not in the config; Stage 2 must capture it from FunASR's generate() to round-trip into the GGUF chat template KV.", "Sibling variant in the wild: FunAudioLLM/Fun-ASR-MLT-Nano-2512 covers 31 languages with the same architecture. Family naming chosen (`funasr_nano`) accommodates it as a sibling variant when ported. Stage 1 confirms only fun-asr-nano-2512 here.", "ITN (`itn=True`) is exposed at the Python API level. SenseVoice handled this via prefix tokens; Fun-ASR-Nano likely handles it via the LLM prompt template. Stage 2 must capture the on/off prompt difference for round-trip into the C++ public API (mirroring the transcribe_sensevoice_params pattern landed earlier).", - "FunASR's VAD path uses a separate fsmn-vad model and is NOT a property of FunASRNano itself; capabilities.voice_activity_detection is therefore false. Same for diarization. Streaming is also false despite README marketing claims — the LLM decode loop is non-streaming." + "FunASR ships a separate fsmn-vad model for voice activity, but that is NOT a property of FunASRNano itself. Diarization likewise relies on separate models; capabilities.speaker_diarization is therefore false. Streaming is also false despite README marketing claims — the LLM decode loop is non-streaming." ], "intake_gaps": [ { diff --git a/reports/porting/gigaam/gigaam-v3-ctc/intake.json b/reports/porting/gigaam/gigaam-v3-ctc/intake.json index 4f38c103..2a514a91 100644 --- a/reports/porting/gigaam/gigaam-v3-ctc/intake.json +++ b/reports/porting/gigaam/gigaam-v3-ctc/intake.json @@ -152,7 +152,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/gigaam/gigaam-v3-e2e-ctc/intake.json b/reports/porting/gigaam/gigaam-v3-e2e-ctc/intake.json index 1c1faf1b..6d1552f5 100644 --- a/reports/porting/gigaam/gigaam-v3-e2e-ctc/intake.json +++ b/reports/porting/gigaam/gigaam-v3-e2e-ctc/intake.json @@ -151,7 +151,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/gigaam/gigaam-v3-e2e-rnnt/intake.json b/reports/porting/gigaam/gigaam-v3-e2e-rnnt/intake.json index ac9fa516..7ec254dc 100644 --- a/reports/porting/gigaam/gigaam-v3-e2e-rnnt/intake.json +++ b/reports/porting/gigaam/gigaam-v3-e2e-rnnt/intake.json @@ -155,7 +155,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/gigaam/gigaam-v3-rnnt/intake.json b/reports/porting/gigaam/gigaam-v3-rnnt/intake.json index 9a696751..0a809bdc 100644 --- a/reports/porting/gigaam/gigaam-v3-rnnt/intake.json +++ b/reports/porting/gigaam/gigaam-v3-rnnt/intake.json @@ -159,7 +159,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/granite/granite-4.0-1b-speech/intake.json b/reports/porting/granite/granite-4.0-1b-speech/intake.json index 20a421fe..e189d527 100644 --- a/reports/porting/granite/granite-4.0-1b-speech/intake.json +++ b/reports/porting/granite/granite-4.0-1b-speech/intake.json @@ -165,9 +165,17 @@ "languages": ["en", "fr", "de", "es", "pt", "ja"], "language_detection": false, "translation": true, + "translation_target_languages": ["en", "fr", "de", "es", "pt", "ja", "it", "zh"], + "translation_pairs": [ + "en>fr", "fr>en", + "en>de", "de>en", + "en>es", "es>en", + "en>pt", "pt>en", + "en>ja", "ja>en", + "en>it", "en>zh" + ], "timestamps": ["none"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/granite/granite-speech-4.1-2b-plus/intake.json b/reports/porting/granite/granite-speech-4.1-2b-plus/intake.json index 60bb6633..cbb41c94 100644 --- a/reports/porting/granite/granite-speech-4.1-2b-plus/intake.json +++ b/reports/porting/granite/granite-speech-4.1-2b-plus/intake.json @@ -162,7 +162,6 @@ "translation": false, "timestamps": ["word"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": true }, "upstream_benchmarks": [ diff --git a/reports/porting/granite/granite-speech-4.1-2b/intake.json b/reports/porting/granite/granite-speech-4.1-2b/intake.json index 55e78f68..21f58e10 100644 --- a/reports/porting/granite/granite-speech-4.1-2b/intake.json +++ b/reports/porting/granite/granite-speech-4.1-2b/intake.json @@ -159,9 +159,17 @@ "languages": ["en", "fr", "de", "es", "pt", "ja"], "language_detection": false, "translation": true, + "translation_target_languages": ["en", "fr", "de", "es", "pt", "ja", "it", "zh"], + "translation_pairs": [ + "en>fr", "fr>en", + "en>de", "de>en", + "en>es", "es>en", + "en>pt", "pt>en", + "en>ja", "ja>en", + "en>it", "en>zh" + ], "timestamps": ["none"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/granite_nar/granite-speech-4.1-2b-nar/intake.json b/reports/porting/granite_nar/granite-speech-4.1-2b-nar/intake.json index 24750582..fb454686 100644 --- a/reports/porting/granite_nar/granite-speech-4.1-2b-nar/intake.json +++ b/reports/porting/granite_nar/granite-speech-4.1-2b-nar/intake.json @@ -2,7 +2,7 @@ "schema_version": "transcribe-intake-v1", "family": "granite_nar", "hf_repo": "ibm-granite/granite-speech-4.1-2b-nar", - "hf_revision": "7d20732df04d097262c4ecd8fe7f34ec2b3e6c42", + "hf_revision": "99a4df9007ac5682f9daa093fb7008ff606e9a5d", "sources": { "config": { "kind": "hf_file", @@ -159,7 +159,6 @@ "translation": false, "timestamps": ["none"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/medasr/medasr/intake.json b/reports/porting/medasr/medasr/intake.json index 771a40b2..6c724782 100644 --- a/reports/porting/medasr/medasr/intake.json +++ b/reports/porting/medasr/medasr/intake.json @@ -150,7 +150,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/moonshine/moonshine-base/intake.json b/reports/porting/moonshine/moonshine-base/intake.json index 168c73eb..d774c999 100644 --- a/reports/porting/moonshine/moonshine-base/intake.json +++ b/reports/porting/moonshine/moonshine-base/intake.json @@ -133,7 +133,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/moonshine/moonshine-tiny/intake.json b/reports/porting/moonshine/moonshine-tiny/intake.json index d99d79b6..4e565ae0 100644 --- a/reports/porting/moonshine/moonshine-tiny/intake.json +++ b/reports/porting/moonshine/moonshine-tiny/intake.json @@ -134,7 +134,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/moonshine_streaming/moonshine-streaming-medium/intake.json b/reports/porting/moonshine_streaming/moonshine-streaming-medium/intake.json index 561b6cda..07d2c3ec 100644 --- a/reports/porting/moonshine_streaming/moonshine-streaming-medium/intake.json +++ b/reports/porting/moonshine_streaming/moonshine-streaming-medium/intake.json @@ -107,7 +107,6 @@ "translation": false, "timestamps": [], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/moonshine_streaming/moonshine-streaming-small/intake.json b/reports/porting/moonshine_streaming/moonshine-streaming-small/intake.json index ee74b352..d9b96d20 100644 --- a/reports/porting/moonshine_streaming/moonshine-streaming-small/intake.json +++ b/reports/porting/moonshine_streaming/moonshine-streaming-small/intake.json @@ -107,7 +107,6 @@ "translation": false, "timestamps": [], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/moonshine_streaming/moonshine-streaming-tiny/intake.json b/reports/porting/moonshine_streaming/moonshine-streaming-tiny/intake.json index 55f636b0..59886fc0 100644 --- a/reports/porting/moonshine_streaming/moonshine-streaming-tiny/intake.json +++ b/reports/porting/moonshine_streaming/moonshine-streaming-tiny/intake.json @@ -150,7 +150,6 @@ "translation": false, "timestamps": [], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/parakeet/nemotron-3.5-asr-streaming-0.6b/intake.json b/reports/porting/parakeet/nemotron-3.5-asr-streaming-0.6b/intake.json index 8b4e220a..7c0c9a8a 100644 --- a/reports/porting/parakeet/nemotron-3.5-asr-streaming-0.6b/intake.json +++ b/reports/porting/parakeet/nemotron-3.5-asr-streaming-0.6b/intake.json @@ -159,7 +159,6 @@ "translation": false, "timestamps": ["token", "word"], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/parakeet/nemotron-speech-streaming-en-0.6b/intake.json b/reports/porting/parakeet/nemotron-speech-streaming-en-0.6b/intake.json index 801f8f1a..a927d9cf 100644 --- a/reports/porting/parakeet/nemotron-speech-streaming-en-0.6b/intake.json +++ b/reports/porting/parakeet/nemotron-speech-streaming-en-0.6b/intake.json @@ -129,7 +129,6 @@ "translation": false, "timestamps": ["token", "word"], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/parakeet/parakeet-ctc-0.6b/intake.json b/reports/porting/parakeet/parakeet-ctc-0.6b/intake.json index caa3d8a2..53ff9c39 100644 --- a/reports/porting/parakeet/parakeet-ctc-0.6b/intake.json +++ b/reports/porting/parakeet/parakeet-ctc-0.6b/intake.json @@ -123,7 +123,6 @@ "translation": false, "timestamps": ["token", "word"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/parakeet/parakeet-ctc-1.1b/intake.json b/reports/porting/parakeet/parakeet-ctc-1.1b/intake.json index 954ba712..e29ff044 100644 --- a/reports/porting/parakeet/parakeet-ctc-1.1b/intake.json +++ b/reports/porting/parakeet/parakeet-ctc-1.1b/intake.json @@ -123,7 +123,6 @@ "translation": false, "timestamps": ["token", "word"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/parakeet/parakeet-rnnt-0.6b/intake.json b/reports/porting/parakeet/parakeet-rnnt-0.6b/intake.json index d4951388..ba5b3ed2 100644 --- a/reports/porting/parakeet/parakeet-rnnt-0.6b/intake.json +++ b/reports/porting/parakeet/parakeet-rnnt-0.6b/intake.json @@ -121,7 +121,6 @@ "translation": false, "timestamps": ["token", "word"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/parakeet/parakeet-rnnt-1.1b/intake.json b/reports/porting/parakeet/parakeet-rnnt-1.1b/intake.json index be01cbb1..b1182568 100644 --- a/reports/porting/parakeet/parakeet-rnnt-1.1b/intake.json +++ b/reports/porting/parakeet/parakeet-rnnt-1.1b/intake.json @@ -116,7 +116,6 @@ "translation": false, "timestamps": ["token", "word"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/parakeet/parakeet-tdt-0.6b-v2/intake.json b/reports/porting/parakeet/parakeet-tdt-0.6b-v2/intake.json index 418a5d35..c0970059 100644 --- a/reports/porting/parakeet/parakeet-tdt-0.6b-v2/intake.json +++ b/reports/porting/parakeet/parakeet-tdt-0.6b-v2/intake.json @@ -133,7 +133,6 @@ "translation": false, "timestamps": ["token", "word", "segment"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/parakeet/parakeet-tdt-0.6b-v3/intake.json b/reports/porting/parakeet/parakeet-tdt-0.6b-v3/intake.json index 5f1c714f..2a22e337 100644 --- a/reports/porting/parakeet/parakeet-tdt-0.6b-v3/intake.json +++ b/reports/porting/parakeet/parakeet-tdt-0.6b-v3/intake.json @@ -137,7 +137,6 @@ "translation": false, "timestamps": ["token", "word", "segment"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/parakeet/parakeet-tdt-1.1b/intake.json b/reports/porting/parakeet/parakeet-tdt-1.1b/intake.json index b141fb18..8111c522 100644 --- a/reports/porting/parakeet/parakeet-tdt-1.1b/intake.json +++ b/reports/porting/parakeet/parakeet-tdt-1.1b/intake.json @@ -119,7 +119,6 @@ "translation": false, "timestamps": ["token", "word", "segment"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/parakeet/parakeet-tdt_ctc-1.1b/intake.json b/reports/porting/parakeet/parakeet-tdt_ctc-1.1b/intake.json index 7b2f143f..35614d32 100644 --- a/reports/porting/parakeet/parakeet-tdt_ctc-1.1b/intake.json +++ b/reports/porting/parakeet/parakeet-tdt_ctc-1.1b/intake.json @@ -120,7 +120,6 @@ "translation": false, "timestamps": ["token", "word", "segment"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/parakeet/parakeet-tdt_ctc-110m/intake.json b/reports/porting/parakeet/parakeet-tdt_ctc-110m/intake.json index be3fcf38..795cde32 100644 --- a/reports/porting/parakeet/parakeet-tdt_ctc-110m/intake.json +++ b/reports/porting/parakeet/parakeet-tdt_ctc-110m/intake.json @@ -118,7 +118,6 @@ "translation": false, "timestamps": ["token", "word", "segment"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/parakeet/parakeet-unified-en-0.6b/intake.json b/reports/porting/parakeet/parakeet-unified-en-0.6b/intake.json index 25c501e6..b90f5edc 100644 --- a/reports/porting/parakeet/parakeet-unified-en-0.6b/intake.json +++ b/reports/porting/parakeet/parakeet-unified-en-0.6b/intake.json @@ -118,7 +118,6 @@ "translation": false, "timestamps": ["token", "word"], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/qwen3_asr/qwen3-asr-0.6b/intake.json b/reports/porting/qwen3_asr/qwen3-asr-0.6b/intake.json index ab904dca..c6a89657 100644 --- a/reports/porting/qwen3_asr/qwen3-asr-0.6b/intake.json +++ b/reports/porting/qwen3_asr/qwen3-asr-0.6b/intake.json @@ -154,7 +154,6 @@ "translation": false, "timestamps": ["none"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/qwen3_asr/qwen3-asr-1.7b/intake.json b/reports/porting/qwen3_asr/qwen3-asr-1.7b/intake.json index b19ce17f..07f1f0d5 100644 --- a/reports/porting/qwen3_asr/qwen3-asr-1.7b/intake.json +++ b/reports/porting/qwen3_asr/qwen3-asr-1.7b/intake.json @@ -93,7 +93,6 @@ "translation": null, "timestamps": [], "streaming": false, - "voice_activity_detection": null, "speaker_diarization": null }, "upstream_benchmarks": [], diff --git a/reports/porting/sensevoice/sensevoice-small/intake.json b/reports/porting/sensevoice/sensevoice-small/intake.json index 4f5abdee..72d7fa2d 100644 --- a/reports/porting/sensevoice/sensevoice-small/intake.json +++ b/reports/porting/sensevoice/sensevoice-small/intake.json @@ -168,7 +168,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/voxtral/voxtral-mini-3b-2507/intake.json b/reports/porting/voxtral/voxtral-mini-3b-2507/intake.json index 6acb650b..e5fbc352 100644 --- a/reports/porting/voxtral/voxtral-mini-3b-2507/intake.json +++ b/reports/porting/voxtral/voxtral-mini-3b-2507/intake.json @@ -146,7 +146,6 @@ "translation": true, "timestamps": ["none"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/voxtral/voxtral-small-24b-2507/intake.json b/reports/porting/voxtral/voxtral-small-24b-2507/intake.json index c744bba1..0be576f3 100644 --- a/reports/porting/voxtral/voxtral-small-24b-2507/intake.json +++ b/reports/porting/voxtral/voxtral-small-24b-2507/intake.json @@ -146,7 +146,6 @@ "translation": true, "timestamps": ["none"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/voxtral_realtime/voxtral-mini-4b-realtime-2602/intake.json b/reports/porting/voxtral_realtime/voxtral-mini-4b-realtime-2602/intake.json index acd91e87..4e2d5f53 100644 --- a/reports/porting/voxtral_realtime/voxtral-mini-4b-realtime-2602/intake.json +++ b/reports/porting/voxtral_realtime/voxtral-mini-4b-realtime-2602/intake.json @@ -163,7 +163,6 @@ "none" ], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/whisper/whisper-base.en/intake.json b/reports/porting/whisper/whisper-base.en/intake.json index 165cafc2..8eb30db5 100644 --- a/reports/porting/whisper/whisper-base.en/intake.json +++ b/reports/porting/whisper/whisper-base.en/intake.json @@ -107,7 +107,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/whisper/whisper-base/intake.json b/reports/porting/whisper/whisper-base/intake.json index 88a7ddd4..b072a346 100644 --- a/reports/porting/whisper/whisper-base/intake.json +++ b/reports/porting/whisper/whisper-base/intake.json @@ -205,7 +205,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/whisper/whisper-large-v2/intake.json b/reports/porting/whisper/whisper-large-v2/intake.json index 4e8da0d3..5873a828 100644 --- a/reports/porting/whisper/whisper-large-v2/intake.json +++ b/reports/porting/whisper/whisper-large-v2/intake.json @@ -205,7 +205,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/whisper/whisper-large-v3-turbo/intake.json b/reports/porting/whisper/whisper-large-v3-turbo/intake.json index c3d4f270..f9b28b5b 100644 --- a/reports/porting/whisper/whisper-large-v3-turbo/intake.json +++ b/reports/porting/whisper/whisper-large-v3-turbo/intake.json @@ -206,7 +206,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/whisper/whisper-large-v3/intake.json b/reports/porting/whisper/whisper-large-v3/intake.json index c4f06d67..c00276b0 100644 --- a/reports/porting/whisper/whisper-large-v3/intake.json +++ b/reports/porting/whisper/whisper-large-v3/intake.json @@ -206,7 +206,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/whisper/whisper-large/intake.json b/reports/porting/whisper/whisper-large/intake.json index db077f4c..0d50aa3c 100644 --- a/reports/porting/whisper/whisper-large/intake.json +++ b/reports/porting/whisper/whisper-large/intake.json @@ -205,7 +205,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/whisper/whisper-medium.en/intake.json b/reports/porting/whisper/whisper-medium.en/intake.json index fdef95bb..f42d8916 100644 --- a/reports/porting/whisper/whisper-medium.en/intake.json +++ b/reports/porting/whisper/whisper-medium.en/intake.json @@ -107,7 +107,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/whisper/whisper-medium/intake.json b/reports/porting/whisper/whisper-medium/intake.json index a03bc53e..142cd771 100644 --- a/reports/porting/whisper/whisper-medium/intake.json +++ b/reports/porting/whisper/whisper-medium/intake.json @@ -221,7 +221,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/whisper/whisper-small.en/intake.json b/reports/porting/whisper/whisper-small.en/intake.json index ac4895ba..07203b12 100644 --- a/reports/porting/whisper/whisper-small.en/intake.json +++ b/reports/porting/whisper/whisper-small.en/intake.json @@ -107,7 +107,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/whisper/whisper-small/intake.json b/reports/porting/whisper/whisper-small/intake.json index 918421e9..f5bd72f9 100644 --- a/reports/porting/whisper/whisper-small/intake.json +++ b/reports/porting/whisper/whisper-small/intake.json @@ -205,7 +205,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/whisper/whisper-tiny.en/intake.json b/reports/porting/whisper/whisper-tiny.en/intake.json index a0539c96..b2d82895 100644 --- a/reports/porting/whisper/whisper-tiny.en/intake.json +++ b/reports/porting/whisper/whisper-tiny.en/intake.json @@ -107,7 +107,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/reports/porting/whisper/whisper-tiny/intake.json b/reports/porting/whisper/whisper-tiny/intake.json index 1d41b3ec..bbfb2489 100644 --- a/reports/porting/whisper/whisper-tiny/intake.json +++ b/reports/porting/whisper/whisper-tiny/intake.json @@ -140,7 +140,6 @@ "translation": true, "timestamps": ["segment", "word"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "upstream_benchmarks": [ diff --git a/scripts/convert-canary-qwen.py b/scripts/convert-canary-qwen.py index 3200ee90..c61b6028 100755 --- a/scripts/convert-canary-qwen.py +++ b/scripts/convert-canary-qwen.py @@ -76,12 +76,14 @@ from typing import Any import numpy as np -from gguf import GGMLQuantizationType, GGUFWriter, LlamaFileType +from gguf import GGMLQuantizationType, LlamaFileType sys.path.insert(0, str(Path(__file__).resolve().parent)) from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, + add_general_identity, encode_for_gguf, gguf_name, reference_dtype_for, @@ -102,7 +104,8 @@ VARIANT_PROFILES: dict[str, dict[str, Any]] = { "canary-qwen-2.5b": { "size_label": "2.5B", - "license": "CC-BY-4.0", + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", "license_link": "https://creativecommons.org/licenses/by/4.0/", "languages": ["en"], # Upstream tokenizer to pull vocab.json + merges.txt + chat_template @@ -174,7 +177,12 @@ def load_salm(model_spec: str): def fetch_lm_tokenizer(lm_repo: str) -> Path: """Snapshot-download the LM tokenizer files into the standard HF - cache (or $TRANSCRIBE_MODELS_DIR//) and return the local dir.""" + cache (or $TRANSCRIBE_MODELS_DIR//) and return the local dir. + + Intentionally does not use lib.hf_source.download_snapshot: this is a + partial fetch (allow_patterns) of tokenizer files from the LM backbone + repo, not a full checkpoint snapshot of the model being converted. + """ from huggingface_hub import snapshot_download slug = slug_from_repo_id(lm_repo) @@ -591,7 +599,7 @@ def resolve_variant(model_spec: str, repo_id_arg: str | None) -> tuple[str, dict # --------------------------------------------------------------------------- -def convert(model_spec: str, out_path: Path, variant: str, profile: dict) -> None: +def convert(model_spec: str, out_path: Path, variant: str, profile: dict, repo_id: str | None = None) -> None: print(f"Output dtype: {REFERENCE_DTYPE_LABEL} (source/reference dtype)") model = load_salm(model_spec) @@ -665,15 +673,23 @@ def convert(model_spec: str, out_path: Path, variant: str, profile: dict) -> Non print(f"Tokenizer: {len(tok['tokens'])} tokens, {len(tok['merges'])} merges") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "canary_qwen") + writer = gguf_writer(str(out_path), "canary_qwen") # ---- general.* ---- - writer.add_string("general.basename", "canary-qwen") - writer.add_string("general.size_label", profile["size_label"]) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array ("general.languages", profile["languages"]) - writer.add_string("general.license", profile["license"]) - writer.add_string("general.license.link", profile["license_link"]) + add_general_identity( + writer, + name="Canary-Qwen 2.5B", + basename="canary-qwen", + size_label=profile["size_label"], + file_type=REFERENCE_FILE_TYPE, + languages=profile["languages"], + author="NVIDIA", + organization="nvidia", + license=profile["license"], + license_name=profile["license_name"], + license_link=profile["license_link"], + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) # ---- stt.variant + capability KV ---- writer.add_string("stt.variant", variant) @@ -917,7 +933,7 @@ def main(argv: list[str]) -> int: out_path = REPO_ROOT / "models" / variant / gguf_name(variant, REFERENCE_DTYPE_LABEL) out_path.parent.mkdir(parents=True, exist_ok=True) - convert(args.model, out_path, variant, profile) + convert(args.model, out_path, variant, profile, repo_id=(args.repo_id or args.model)) return 0 diff --git a/scripts/convert-canary.py b/scripts/convert-canary.py index b48f6461..15a47f73 100644 --- a/scripts/convert-canary.py +++ b/scripts/convert-canary.py @@ -61,13 +61,15 @@ from typing import Any import numpy as np -from gguf import GGUFWriter, LlamaFileType +from gguf import LlamaFileType sys.path.insert(0, str(Path(__file__).resolve().parent)) from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, TOKEN_TYPE_UNKNOWN, + add_general_identity, gguf_name, slug_from_repo_id, ) @@ -89,39 +91,61 @@ VARIANT_PROFILES: dict[str, dict[str, Any]] = { "canary-1b": { + "display_name": "Canary 1B", "version": "v1", "size_label": "1B", "prompt_format": "canary", # canary-1b is the only family member under a non-commercial # license. Surface it in general.license so downstream tooling # (and humans inspecting the GGUF) cannot miss the distinction. - "license": "CC-BY-NC-4.0", + "license": "cc-by-nc-4.0", + "license_name": "Creative Commons Attribution-NonCommercial 4.0", "license_link": "https://creativecommons.org/licenses/by-nc/4.0/", }, "canary-1b-v2": { + "display_name": "Canary 1B v2", "version": "v2", + # AST excludes Latvian (lv): NVIDIA's card notes seamless-m4t, used + # to build the AST training data, does not support Latvian — so v2 + # translates 24 of its 25 ASR languages. lv stays ASR-only. + "translation_exclude": ["lv"], "size_label": "1B", "prompt_format": "canary2", - "license": "CC-BY-4.0", + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", "license_link": "https://creativecommons.org/licenses/by/4.0/", }, "canary-1b-flash": { + "display_name": "Canary 1B Flash", "version": "1b-flash", "size_label": "1B", "prompt_format": "canary2", - "license": "CC-BY-4.0", + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", "license_link": "https://creativecommons.org/licenses/by/4.0/", }, "canary-180m-flash": { + "display_name": "Canary 180M Flash", "version": "180m-flash", "size_label": "180M", "prompt_format": "canary2", - "license": "CC-BY-4.0", + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", "license_link": "https://creativecommons.org/licenses/by/4.0/", }, } +def english_pivot_pairs(languages: list[str]) -> list[str]: + """Canary translation is advertised only between English and X.""" + return [ + pair + for lang in languages + if lang != "en" + for pair in (f"en>{lang}", f"{lang}>en") + ] + + # --------------------------------------------------------------------------- # Model loading # --------------------------------------------------------------------------- @@ -513,7 +537,7 @@ def resolve_variant(model_spec: str, repo_id_arg: str | None) -> tuple[str, dict # --------------------------------------------------------------------------- -def convert(model_spec: str, out_path: Path, variant: str, profile: dict, languages: list[str]) -> None: +def convert(model_spec: str, out_path: Path, variant: str, profile: dict, languages: list[str], repo_id: str | None = None) -> None: from omegaconf import OmegaConf print(f"Output dtype: {REFERENCE_DTYPE_LABEL} (source/reference dtype)") @@ -566,21 +590,36 @@ def convert(model_spec: str, out_path: Path, variant: str, profile: dict, langua print(f"encoder_decoder_proj: {'present' if has_proj else 'absent (dims match)'}") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "canary") + writer = gguf_writer(str(out_path), "canary") # ----- general.* ----- - writer.add_string("general.basename", "canary") - writer.add_string("general.size_label", profile["size_label"]) - writer.add_string("general.version", profile["version"]) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array ("general.languages", languages) - writer.add_string("general.license", profile["license"]) - writer.add_string("general.license.link", profile["license_link"]) - - # ----- stt.variant + capability KV ----- + add_general_identity( + writer, + name=profile["display_name"], + basename="canary", + size_label=profile["size_label"], + file_type=REFERENCE_FILE_TYPE, + languages=languages, + author="NVIDIA", + organization="nvidia", + version=profile["version"], + license=profile["license"], + license_name=profile["license_name"], + license_link=profile["license_link"], + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) + + # ----- stt.variant + capability/translation KVs ----- writer.add_string("stt.variant", variant) writer.add_bool ("stt.capability.translate", True) writer.add_bool ("stt.capability.lang_detect", False) + # Translation may cover a subset of the ASR languages (e.g. v2 omits + # Latvian). Derive the AST set from the ASR list minus the per-variant + # exclusion so target_languages and pairs stay accurate to the card. + xlat_exclude = set(profile.get("translation_exclude", ())) + xlat_langs = [lang for lang in languages if lang not in xlat_exclude] + writer.add_array ("stt.translation.target_languages", xlat_langs) + writer.add_array ("stt.translation.pairs", english_pivot_pairs(xlat_langs)) # All shipping variants except canary-1b expose timestamp tokens. has_timestamps = "<|timestamp|>" in tok["specials"] writer.add_bool ("stt.capability.timestamps", has_timestamps) @@ -837,7 +876,7 @@ def main(argv: list[str]) -> int: out_path = REPO_ROOT / "models" / variant / gguf_name(variant, REFERENCE_DTYPE_LABEL) out_path.parent.mkdir(parents=True, exist_ok=True) - convert(args.model, out_path, variant, profile, languages) + convert(args.model, out_path, variant, profile, languages, repo_id=(args.repo_id or args.model)) return 0 diff --git a/scripts/convert-cohere.py b/scripts/convert-cohere.py index a160b95d..25db603c 100755 --- a/scripts/convert-cohere.py +++ b/scripts/convert-cohere.py @@ -73,24 +73,25 @@ import argparse import json -import os import sys from pathlib import Path import numpy as np import torch -from gguf import GGMLQuantizationType, GGUFWriter, LlamaFileType -from huggingface_hub import snapshot_download +from gguf import GGMLQuantizationType, LlamaFileType from safetensors import safe_open import sentencepiece as spm sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib.hf_source import download_snapshot, looks_like_repo_id # noqa: E402 from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_BYTE, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, TOKEN_TYPE_UNKNOWN, TOKEN_TYPE_UNUSED, + add_general_identity, encode_for_gguf, gguf_name, reference_dtype_for, @@ -404,7 +405,7 @@ def _compute_size_label(total_params: int) -> str: # --------------------------------------------------------------------------- -def convert(model_dir: Path, out_path: Path) -> None: +def convert(model_dir: Path, out_path: Path, repo_id: str | None = None) -> None: print(f"Output dtype: {REFERENCE_DTYPE_LABEL} (source/reference dtype)") config_path = model_dir / "config.json" @@ -456,13 +457,24 @@ def convert(model_dir: Path, out_path: Path) -> None: print(f"Total params (deduplicated): {total_params:,} -> size_label={size_label}") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "cohere_asr") + writer = gguf_writer(str(out_path), "cohere_asr") # ----- general.* metadata ----- - writer.add_string("general.basename", "cohere-transcribe") - writer.add_string("general.size_label", size_label) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array("general.languages", hp["languages"]) + add_general_identity( + writer, + name="Cohere Transcribe", + version="03-2026", + basename="cohere-transcribe", + size_label=size_label, + file_type=REFERENCE_FILE_TYPE, + languages=hp["languages"], + author="Cohere", + organization="CohereLabs", + license="apache-2.0", + license_name="Apache License 2.0", + license_link="https://www.apache.org/licenses/LICENSE-2.0", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) # ----- stt.variant ----- writer.add_string("stt.variant", "cohere-transcribe-03-2026") @@ -688,30 +700,6 @@ def add(src_name: str, gguf_name: str, transform) -> None: print(f"Done. Wrote {out_path} ({out_path.stat().st_size / (1024 * 1024):.1f} MB)") -def _looks_like_repo_id(s: str) -> bool: - """`org/name` with no filesystem match. Mirrors convert-parakeet.py.""" - return "/" in s and not Path(s).exists() - - -def _download_snapshot(repo_id: str) -> Path: - """Fetch a cohere checkpoint from HF into $TRANSCRIBE_MODELS_DIR//. - - Falls back to the default HF cache when $TRANSCRIBE_MODELS_DIR is unset - — snapshot_download returns the resolved local path either way. - """ - slug = slug_from_repo_id(repo_id) - models_root = os.environ.get("TRANSCRIBE_MODELS_DIR") - local_dir = Path(models_root) / slug if models_root else None - if local_dir is not None: - local_dir.mkdir(parents=True, exist_ok=True) - print(f"Downloading {repo_id} from Hugging Face...") - resolved = snapshot_download( - repo_id=repo_id, - local_dir=str(local_dir) if local_dir is not None else None, - ) - return Path(resolved) - - def main(argv: list[str]) -> int: p = argparse.ArgumentParser( description="Convert a Cohere ASR checkpoint (HF repo id or local dir) to a GGUF.", @@ -727,9 +715,9 @@ def main(argv: list[str]) -> int: "converting from a local path. Ignored if out_path is given.") args = p.parse_args(argv[1:]) - if _looks_like_repo_id(args.model): + if looks_like_repo_id(args.model): repo_id = args.repo_id or args.model - model_dir = _download_snapshot(args.model) + model_dir = download_snapshot(args.model) else: model_dir = Path(args.model) if not model_dir.is_dir(): @@ -748,7 +736,7 @@ def main(argv: list[str]) -> int: out_path = REPO_ROOT / "models" / slug / gguf_name(slug, REFERENCE_DTYPE_LABEL) out_path.parent.mkdir(parents=True, exist_ok=True) - convert(model_dir, out_path) + convert(model_dir, out_path, repo_id=repo_id) return 0 diff --git a/scripts/convert-funasr_nano.py b/scripts/convert-funasr_nano.py index 7d197bd4..a1baeb42 100644 --- a/scripts/convert-funasr_nano.py +++ b/scripts/convert-funasr_nano.py @@ -98,13 +98,15 @@ import numpy as np import torch import yaml -from gguf import GGMLQuantizationType, GGUFWriter, LlamaFileType -from huggingface_hub import snapshot_download +from gguf import GGMLQuantizationType, LlamaFileType sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib.hf_source import download_snapshot, looks_like_repo_id # noqa: E402 from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, + add_general_identity, encode_for_gguf, gguf_name, slug_from_repo_id, @@ -472,7 +474,7 @@ def compute_size_label(total_params: int) -> str: # --------------------------------------------------------------------------- -def convert(model_dir: Path, out_path: Path, variant: str, display_name: str) -> None: +def convert(model_dir: Path, out_path: Path, variant: str, display_name: str, repo_id: str | None = None) -> None: print(f"Output dtype: {REFERENCE_DTYPE_LABEL} (per-tensor BF16; norms/biases F32)") _patch_fun_asr_nano_imports() @@ -555,50 +557,59 @@ def convert(model_dir: Path, out_path: Path, variant: str, display_name: str) -> print(f"Total params: {total_params:,} -> size_label={size_label}") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "funasr_nano") + writer = gguf_writer(str(out_path), "funasr_nano") # ----- general.* ----- - # FunASR Model Open Source License Agreement v1.1 attribution - # requirement (`MODEL_LICENSE` 2.2): "you must attribute the source - # and author information and retain relevant model names". Bake the - # canonical attribution into the GGUF KV so downstream consumers - # (anyone loading the converted file) see source + author + model - # names without having to read external docs. - writer.add_string("general.name", display_name) - writer.add_string("general.basename", variant.rsplit("-", 1)[0] if "-" in variant else variant) - writer.add_string("general.size_label", size_label) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array ("general.languages", hp["languages"]) - writer.add_string("general.author", "Alibaba Group / FunAudioLLM") - writer.add_string("general.organization", "FunAudioLLM") - writer.add_string("general.license", "FunASR-Model-License-1.1") - writer.add_string("general.license.link", - "https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE") - writer.add_string("general.url", - f"https://huggingface.co/FunAudioLLM/{display_name}") - writer.add_string("general.source.url", - "https://github.com/modelscope/FunASR") - # Model-name retention (license clause 2.2): list every canonical - # upstream component the checkpoint stitches together. Anyone using, - # copying, modifying, or sharing this GGUF must keep these visible. - writer.add_array("general.tags", [ - "asr", - "speech-recognition", - "audio-llm", - "FunASRNano", - display_name, - "SenseVoiceEncoderSmall", - "Qwen3-0.6B", - ]) - writer.add_string("general.description", - f"{display_name} (Alibaba FunAudioLLM): " - "SenseVoiceEncoderSmall encoder + 2-layer audio " - "adaptor + Qwen3-0.6B LLM. Bundled Qwen3-0.6B " - "weights are derivative of Qwen/Qwen3-0.6B " - "(Apache-2.0). Converted from FunAudioLLM/" - f"{display_name} model.pt; see " - "https://github.com/modelscope/FunASR/blob/main/" - "MODEL_LICENSE for FunASR redistribution terms.") + # Clean per-variant display name (the headline general.name). The + # `display_name` variable below is the repo-cased slug, still used for + # the url / tags / description attribution strings. + _CLEAN_NAME = { + "fun-asr-nano-2512": "Fun-ASR Nano", + "fun-asr-mlt-nano-2512": "Fun-ASR Nano Multilingual", + } + clean_name = _CLEAN_NAME.get(variant) + if clean_name is None: + raise RuntimeError( + f"unrecognised funasr_nano variant {variant!r}; " + f"expected one of {sorted(_CLEAN_NAME)}" + ) + add_general_identity( + writer, + name=clean_name, + basename=variant.rsplit("-", 1)[0] if "-" in variant else variant, + size_label=size_label, + file_type=int(REFERENCE_FILE_TYPE), + languages=hp["languages"], + version="2512", + author="Alibaba Group / FunAudioLLM", + organization="FunAudioLLM", + license="apache-2.0", + license_name="Apache License 2.0", + license_link="https://www.apache.org/licenses/LICENSE-2.0", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + url=f"https://huggingface.co/FunAudioLLM/{display_name}", + source_url="https://github.com/modelscope/FunASR", + # Component attribution: list every canonical upstream component + # the checkpoint stitches together so downstream consumers see + # source + model names without reading external docs. + tags=[ + "asr", + "speech-recognition", + "audio-llm", + "FunASRNano", + display_name, + "SenseVoiceEncoderSmall", + "Qwen3-0.6B", + ], + description=( + f"{display_name} (Alibaba FunAudioLLM): " + "SenseVoiceEncoderSmall encoder + 2-layer audio " + "adaptor + Qwen3-0.6B LLM. Bundled Qwen3-0.6B " + "weights are derivative of Qwen/Qwen3-0.6B " + "(Apache-2.0). Converted from FunAudioLLM/" + f"{display_name} model.pt." + ), + ) # ----- stt.variant ----- writer.add_string("stt.variant", variant) @@ -804,29 +815,6 @@ def add_tensor(src_name: str, dst_name: str) -> None: # --------------------------------------------------------------------------- -def _looks_like_repo_id(s: str) -> bool: - return "/" in s and not Path(s).exists() - - -def _download_snapshot(repo_id: str, revision: str | None) -> Path: - slug = slug_from_repo_id(repo_id) - models_root = os.environ.get("TRANSCRIBE_MODELS_DIR") - local_dir = Path(models_root) / slug if models_root else None - if local_dir is not None: - local_dir.mkdir(parents=True, exist_ok=True) - if revision: - print(f"Downloading {repo_id}@{revision} from Hugging Face...") - else: - print(f"Downloading {repo_id} from Hugging Face " - f"(no revision pin; reproducibility depends on upstream)...") - resolved = snapshot_download( - repo_id=repo_id, - revision=revision, - local_dir=str(local_dir) if local_dir is not None else None, - ) - return Path(resolved) - - SLUG_TO_VARIANT = { "Fun-ASR-Nano-2512": "fun-asr-nano-2512", } @@ -850,9 +838,9 @@ def main(argv: list[str]) -> int: help="stt.variant string (default: derived from slug)") args = p.parse_args(argv[1:]) - if _looks_like_repo_id(args.model): + if looks_like_repo_id(args.model): repo_id = args.repo_id or args.model - model_dir = _download_snapshot(args.model, args.revision) + model_dir = download_snapshot(args.model, args.revision) else: model_dir = Path(args.model) if not model_dir.is_dir(): @@ -884,7 +872,7 @@ def main(argv: list[str]) -> int: else: output_slug = raw_slug or variant - convert(model_dir, out_path, variant, display_name=output_slug) + convert(model_dir, out_path, variant, display_name=output_slug, repo_id=repo_id) return 0 diff --git a/scripts/convert-gigaam.py b/scripts/convert-gigaam.py index f55659c6..5765d582 100644 --- a/scripts/convert-gigaam.py +++ b/scripts/convert-gigaam.py @@ -86,15 +86,17 @@ from pathlib import Path import numpy as np -from gguf import GGMLQuantizationType, GGUFWriter, LlamaFileType +from gguf import GGMLQuantizationType, LlamaFileType sys.path.insert(0, str(Path(__file__).resolve().parent)) from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_BYTE, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, TOKEN_TYPE_UNKNOWN, TOKEN_TYPE_UNUSED, + add_general_identity, gguf_name, safe_id, slug_from_repo_id, @@ -158,6 +160,15 @@ GENERAL_LANGUAGES = ["ru"] +# Friendly general.name per variant slug (== profile["variant"]). +VARIANT_DISPLAY_NAMES: dict[str, str] = { + "gigaam-v3-ctc": "GigaAM v3 CTC", + "gigaam-v3-e2e-ctc": "GigaAM v3 E2E-CTC", + "gigaam-v3-rnnt": "GigaAM v3 RNN-T", + "gigaam-v3-e2e-rnnt": "GigaAM v3 E2E-RNN-T", +} + + # --------------------------------------------------------------------------- # Model loading # --------------------------------------------------------------------------- @@ -477,7 +488,7 @@ def tensor_to_fp32_numpy(t) -> np.ndarray: # --------------------------------------------------------------------------- -def convert(variant_key: str, slug: str, out_path: Path) -> None: +def convert(variant_key: str, slug: str, out_path: Path, repo_id: str | None = None) -> None: from omegaconf import OmegaConf print(f"Output dtype: {REFERENCE_DTYPE_LABEL} (source/reference dtype)") @@ -587,14 +598,29 @@ def convert(variant_key: str, slug: str, out_path: Path) -> None: print(f"Total params (encoder+head): {total:,} -> size_label={size_label}") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "gigaam") + writer = gguf_writer(str(out_path), "gigaam") # ----- general.* ----- - writer.add_string("general.basename", GENERAL_BASENAME) - writer.add_string("general.size_label", size_label) - writer.add_string("general.version", GENERAL_VERSION) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array ("general.languages", GENERAL_LANGUAGES) + if profile["variant"] not in VARIANT_DISPLAY_NAMES: + raise ValueError( + f"unknown gigaam variant: {profile['variant']!r}; " + f"add it to VARIANT_DISPLAY_NAMES" + ) + add_general_identity( + writer, + name=VARIANT_DISPLAY_NAMES[profile["variant"]], + basename=GENERAL_BASENAME, + size_label=size_label, + version=GENERAL_VERSION, + file_type=REFERENCE_FILE_TYPE, + languages=GENERAL_LANGUAGES, + author="Salute Developers", + organization="ai-sage", + license="mit", + license_name="MIT License", + license_link="https://opensource.org/license/mit", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) # ----- stt.variant + head_kind ----- writer.add_string("stt.variant", profile["variant"]) @@ -826,7 +852,7 @@ def main(argv: list[str]) -> int: else: out_path.parent.mkdir(parents=True, exist_ok=True) - convert(variant_key, slug, out_path) + convert(variant_key, slug, out_path, repo_id=args.model) return 0 diff --git a/scripts/convert-granite.py b/scripts/convert-granite.py index cd4fb9d7..719aaaa3 100644 --- a/scripts/convert-granite.py +++ b/scripts/convert-granite.py @@ -52,10 +52,11 @@ general.architecture = "granite_speech" general.basename = "granite-speech" general.size_label = "1.0B" / "2.0B" / ... - general.languages = BCP-47 list (5 or 6 langs) + general.languages = BCP-47 ASR source-language list (5 or 6 langs) stt.variant = e.g. "granite-4.0-1b-speech" - stt.capability.translation = bool (true for 1b/2b; false for plus) + stt.capability.translate = bool (true for 1b/2b; false for plus) + stt.translation.target_languages = BCP-47 target list when translation is true tokenizer.ggml.model = "gpt2" (BPE with byte-level pre-tokenizer) tokenizer.ggml.tokens / merges / token_type @@ -86,21 +87,22 @@ import argparse import json -import os import sys from contextlib import ExitStack from pathlib import Path import numpy as np import torch -from gguf import GGMLQuantizationType, GGUFWriter, LlamaFileType -from huggingface_hub import snapshot_download +from gguf import GGMLQuantizationType, LlamaFileType from safetensors import safe_open sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib.hf_source import download_snapshot, looks_like_repo_id # noqa: E402 from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, + add_general_identity, encode_for_gguf, gguf_name, reference_dtype_for, @@ -123,6 +125,27 @@ "granite-speech-4.1-2b-plus": ["en", "fr", "de", "es", "pt"], } +# Translation targets are not exactly the ASR language set: the base AR +# variants also advertise English-to-Italian and English-to-Mandarin. Keep this +# separate from general.languages so `language=it/zh` is still rejected as an +# unsupported source language while `target_language=it/zh` is accepted. +TRANSLATION_TARGET_LANG_BY_VARIANT = { + "granite-4.0-1b-speech": ["en", "fr", "de", "es", "pt", "ja", "it", "zh"], + "granite-speech-4.1-2b": ["en", "fr", "de", "es", "pt", "ja", "it", "zh"], +} + + +def granite_translation_pairs(asr_langs: list[str]) -> list[str]: + """Model-card translation directions for the base AR variants.""" + pairs: list[str] = [] + for lang in asr_langs: + if lang == "en": + continue + pairs.append(f"en>{lang}") + pairs.append(f"{lang}>en") + pairs.extend(["en>it", "en>zh"]) + return pairs + # --------------------------------------------------------------------------- # Sharded safetensors shim @@ -537,7 +560,7 @@ def compute_size_label(total_params: int) -> str: # --------------------------------------------------------------------------- -def convert(model_dir: Path, out_path: Path, variant: str) -> None: +def convert(model_dir: Path, out_path: Path, variant: str, repo_id: str | None = None) -> None: print(f"Output dtype: {REFERENCE_DTYPE_LABEL} (source/reference dtype)") config_path = model_dir / "config.json" @@ -631,30 +654,50 @@ def convert(model_dir: Path, out_path: Path, variant: str) -> None: f"{total:,} -> size_label={size_label}") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "granite_speech") + writer = gguf_writer(str(out_path), "granite_speech") # ---- general.* ---- - writer.add_string("general.basename", "granite-speech") - writer.add_string("general.size_label", size_label) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - if languages: - writer.add_array("general.languages", languages) + add_general_identity( + writer, + name={ + "granite-4.0-1b-speech": "Granite Speech 4.0 1B", + "granite-speech-4.1-2b": "Granite Speech 4.1 2B", + "granite-speech-4.1-2b-plus": "Granite Speech 4.1 2B Plus", + }[variant], + basename="granite-speech", + size_label=size_label, + file_type=REFERENCE_FILE_TYPE, + languages=(languages if languages else None), + author="IBM", + organization="ibm-granite", + license="apache-2.0", + license_name="Apache License 2.0", + license_link="https://www.apache.org/licenses/LICENSE-2.0", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) # ---- stt.variant ---- writer.add_string("stt.variant", variant) # ---- stt.capability.* ---- - # 1b / 2b advertise X->En and En->X translation; 2b-plus narrows - # to ASR only. Language detection is not exposed by Granite - # (the chat template selects language explicitly). + # 1b / 2b advertise translation to/from English for the ASR languages, + # plus English-to-Italian and English-to-Mandarin. 2b-plus narrows to + # ASR only. Language detection is not exposed by Granite (the chat + # template selects language explicitly). translation_caps = { "granite-4.0-1b-speech": True, "granite-speech-4.1-2b": True, "granite-speech-4.1-2b-plus": False, } - writer.add_bool("stt.capability.translation", - bool(translation_caps.get(variant, False))) + can_translate = bool(translation_caps.get(variant, False)) + writer.add_bool("stt.capability.translate", + can_translate) writer.add_bool("stt.capability.lang_detect", False) + if can_translate: + writer.add_array("stt.translation.target_languages", + TRANSLATION_TARGET_LANG_BY_VARIANT[variant]) + writer.add_array("stt.translation.pairs", + granite_translation_pairs(languages)) # -plus is the only variant exposing word timestamps and # speaker diarization (per its model card). writer.add_bool("stt.capability.word_timestamps", @@ -917,29 +960,6 @@ def add(src_name: str, dst_name: str, transform=passthrough) -> None: print(f"Done. Wrote {out_path} ({out_path.stat().st_size / (1024 * 1024):.1f} MB)") -def _looks_like_repo_id(s: str) -> bool: - return "/" in s and not Path(s).exists() - - -def _download_snapshot(repo_id: str, revision: str | None) -> Path: - slug = slug_from_repo_id(repo_id) - models_root = os.environ.get("TRANSCRIBE_MODELS_DIR") - local_dir = Path(models_root) / slug if models_root else None - if local_dir is not None: - local_dir.mkdir(parents=True, exist_ok=True) - if revision: - print(f"Downloading {repo_id}@{revision} from Hugging Face...") - else: - print(f"Downloading {repo_id} from Hugging Face " - f"(no revision pin; reproducibility depends on upstream)...") - resolved = snapshot_download( - repo_id=repo_id, - revision=revision, - local_dir=str(local_dir) if local_dir is not None else None, - ) - return Path(resolved) - - def main(argv: list[str]) -> int: p = argparse.ArgumentParser( description="Convert a Granite Speech checkpoint to a BF16 accuracy GGUF.", @@ -959,9 +979,9 @@ def main(argv: list[str]) -> int: help="stt.variant string (default: derived from slug)") args = p.parse_args(argv[1:]) - if _looks_like_repo_id(args.model): + if looks_like_repo_id(args.model): repo_id = args.repo_id or args.model - model_dir = _download_snapshot(args.model, args.revision) + model_dir = download_snapshot(args.model, args.revision) else: model_dir = Path(args.model) if not model_dir.is_dir(): @@ -997,7 +1017,7 @@ def main(argv: list[str]) -> int: break variant = stripped - convert(model_dir, out_path, variant) + convert(model_dir, out_path, variant, repo_id=repo_id) return 0 diff --git a/scripts/convert-granite_nar.py b/scripts/convert-granite_nar.py index 889511b6..3b355bc0 100644 --- a/scripts/convert-granite_nar.py +++ b/scripts/convert-granite_nar.py @@ -50,21 +50,19 @@ from gguf import GGUFWriter, GGUFValueType from safetensors.torch import safe_open +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib.hf_source import resolve_model_dir # noqa: E402 +from lib.gguf_common import ( # noqa: E402 + gguf_writer, + add_general_identity, +) + REF_DTYPE = "BF16" TOKEN_TYPE_NORMAL = 1 TOKEN_TYPE_CONTROL = 3 TOKEN_TYPE_USER_DEFINED = 4 -def hf_resolve(model_arg: str, revision: str | None): - """Return a local directory containing the HF model files.""" - p = Path(model_arg).expanduser().resolve() - if p.is_dir(): - return p - from huggingface_hub import snapshot_download - return Path(snapshot_download(model_arg, revision=revision)) - - def read_tokenizer(model_dir: Path) -> dict: """Read tokenizer.json (granite-4 BPE) → tokens / merges / types / specials.""" tok = json.loads((model_dir / "tokenizer.json").read_text()) @@ -471,7 +469,7 @@ def main(argv: list[str]) -> int: repo_id = args.repo_id or args.model variant = repo_id.split("/")[-1] - model_dir = hf_resolve(args.model, args.revision) + model_dir = resolve_model_dir(args.model, args.revision) print(f"Source: {model_dir}") config = json.loads((model_dir / "config.json").read_text()) @@ -499,16 +497,26 @@ def main(argv: list[str]) -> int: out_path = outdir / f"{variant}-{REF_DTYPE}.gguf" print(f"Writing GGUF: {out_path}") - writer = GGUFWriter(str(out_path), "granite_speech_nar") + writer = gguf_writer(str(out_path), "granite_speech_nar") # ---- general.* ---- - writer.add_string("general.basename", "granite-speech-nar") languages = ["en", "fr", "de", "es", "pt"] - writer.add_array("general.languages", languages) + add_general_identity( + writer, + name="Granite Speech 4.1 2B NAR", + basename="granite-speech-nar", + languages=languages, + author="IBM", + organization="ibm-granite", + license="apache-2.0", + license_name="Apache License 2.0", + license_link="https://www.apache.org/licenses/LICENSE-2.0", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) # ---- stt.variant + capabilities ---- writer.add_string("stt.variant", variant) - writer.add_bool("stt.capability.translation", False) + writer.add_bool("stt.capability.translate", False) writer.add_bool("stt.capability.lang_detect", False) writer.add_bool("stt.capability.word_timestamps", False) writer.add_bool("stt.capability.speaker_diarization", False) diff --git a/scripts/convert-medasr.py b/scripts/convert-medasr.py index fb8c1b63..91771058 100644 --- a/scripts/convert-medasr.py +++ b/scripts/convert-medasr.py @@ -70,12 +70,15 @@ from safetensors.torch import safe_open sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib.hf_source import resolve_model_dir # noqa: E402 from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_BYTE, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, TOKEN_TYPE_UNKNOWN, TOKEN_TYPE_UNUSED, + add_general_identity, encode_for_gguf, reference_dtype_for, slug_from_repo_id, @@ -90,14 +93,6 @@ # ---- Source resolution ---------------------------------------------------- -def hf_resolve(model_arg: str, revision: str | None) -> Path: - p = Path(model_arg).expanduser().resolve() - if p.is_dir(): - return p - from huggingface_hub import snapshot_download - return Path(snapshot_download(model_arg, revision=revision)) - - # ---- Hparam extraction ---------------------------------------------------- @@ -350,7 +345,7 @@ def main(argv: list[str]) -> int: repo_id = args.repo_id or args.model slug = slug_from_repo_id(repo_id) - model_dir = hf_resolve(args.model, args.revision) + model_dir = resolve_model_dir(args.model, args.revision) print(f"Source: {model_dir}") config = json.loads((model_dir / "config.json").read_text()) @@ -378,16 +373,26 @@ def main(argv: list[str]) -> int: out_path = outdir / f"{slug}-{REF_DTYPE}.gguf" print(f"Writing GGUF: {out_path}") - writer = GGUFWriter(str(out_path), ARCH_KEY) + writer = gguf_writer(str(out_path), ARCH_KEY) # ---- general.* ---- - writer.add_string("general.basename", "medasr") - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array("general.languages", ["en"]) + add_general_identity( + writer, + name="MedASR", + basename="medasr", + file_type=REFERENCE_FILE_TYPE, + languages=["en"], + author="Google", + organization="google", + license="other", + license_name="health-ai-developer-foundations", + license_link="https://developers.google.com/health-ai-developer-foundations/terms", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) # ---- stt.variant + capabilities ---- writer.add_string("stt.variant", slug) - writer.add_bool("stt.capability.translation", False) + writer.add_bool("stt.capability.translate", False) writer.add_bool("stt.capability.lang_detect", False) writer.add_bool("stt.capability.word_timestamps", False) writer.add_bool("stt.capability.speaker_diarization", False) diff --git a/scripts/convert-moonshine.py b/scripts/convert-moonshine.py index 76123383..164db028 100644 --- a/scripts/convert-moonshine.py +++ b/scripts/convert-moonshine.py @@ -143,22 +143,23 @@ import argparse import json -import os import sys from pathlib import Path import numpy as np import torch -from gguf import GGMLQuantizationType, GGUFWriter, LlamaFileType -from huggingface_hub import snapshot_download +from gguf import GGMLQuantizationType, LlamaFileType from safetensors import safe_open sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib.hf_source import download_snapshot, looks_like_repo_id # noqa: E402 from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_BYTE, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, TOKEN_TYPE_UNKNOWN, + add_general_identity, encode_for_gguf, gguf_name, reference_dtype_for, @@ -477,7 +478,8 @@ def _infer_languages(variant: str) -> list[str]: def convert(model_dir: Path, out_path: Path, variant: str, - languages: list[str] | None = None) -> None: + languages: list[str] | None = None, + repo_id: str | None = None) -> None: config_path = model_dir / "config.json" gen_config_path = model_dir / "generation_config.json" preproc_path = model_dir / "preprocessor_config.json" @@ -535,13 +537,29 @@ def convert(model_dir: Path, out_path: Path, variant: str, conv_channels = [c1_out, c2_out, c3_out] print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "moonshine") + writer = gguf_writer(str(out_path), "moonshine") # ---- general.* ---- - writer.add_string("general.basename", "moonshine") - writer.add_string("general.size_label", size_label) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array("general.languages", languages) + _DISPLAY_NAMES = { + "moonshine-tiny": "Moonshine Tiny", + "moonshine-base": "Moonshine Base", + } + if variant not in _DISPLAY_NAMES: + raise ValueError(f"unknown moonshine variant slug: {variant!r}") + add_general_identity( + writer, + name=_DISPLAY_NAMES[variant], + basename="moonshine", + size_label=size_label, + file_type=int(REFERENCE_FILE_TYPE), + languages=languages, + author="Useful Sensors", + organization="UsefulSensors", + license="mit", + license_name="MIT License", + license_link="https://opensource.org/license/mit", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) # ---- stt.variant ---- writer.add_string("stt.variant", variant) @@ -719,29 +737,6 @@ def add(src_name: str, dst_name: str, transform=passthrough) -> None: print(f"Done. Wrote {out_path} ({out_path.stat().st_size / (1024 * 1024):.1f} MB)") -def _looks_like_repo_id(s: str) -> bool: - return "/" in s and not Path(s).exists() - - -def _download_snapshot(repo_id: str, revision: str | None) -> Path: - slug = slug_from_repo_id(repo_id) - models_root = os.environ.get("TRANSCRIBE_MODELS_DIR") - local_dir = Path(models_root) / slug if models_root else None - if local_dir is not None: - local_dir.mkdir(parents=True, exist_ok=True) - if revision: - print(f"Downloading {repo_id}@{revision} from Hugging Face...") - else: - print(f"Downloading {repo_id} from Hugging Face " - f"(no revision pin; reproducibility depends on upstream)...") - resolved = snapshot_download( - repo_id=repo_id, - revision=revision, - local_dir=str(local_dir) if local_dir is not None else None, - ) - return Path(resolved) - - def main(argv: list[str]) -> int: p = argparse.ArgumentParser( description="Convert a Moonshine checkpoint to a reference-dtype GGUF.", @@ -765,9 +760,9 @@ def main(argv: list[str]) -> int: "moonshine-tiny → ['en']).") args = p.parse_args(argv[1:]) - if _looks_like_repo_id(args.model): + if looks_like_repo_id(args.model): repo_id = args.repo_id or args.model - model_dir = _download_snapshot(args.model, args.revision) + model_dir = download_snapshot(args.model, args.revision) else: model_dir = Path(args.model) if not model_dir.is_dir(): @@ -804,7 +799,7 @@ def main(argv: list[str]) -> int: break variant = stripped - convert(model_dir, out_path, variant, languages=args.language) + convert(model_dir, out_path, variant, languages=args.language, repo_id=repo_id) return 0 diff --git a/scripts/convert-moonshine_streaming.py b/scripts/convert-moonshine_streaming.py index b434308b..5750f503 100644 --- a/scripts/convert-moonshine_streaming.py +++ b/scripts/convert-moonshine_streaming.py @@ -53,22 +53,23 @@ import argparse import json -import os import sys from pathlib import Path import numpy as np import torch -from gguf import GGMLQuantizationType, GGUFWriter, LlamaFileType -from huggingface_hub import snapshot_download +from gguf import GGMLQuantizationType, LlamaFileType from safetensors import safe_open sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib.hf_source import download_snapshot, looks_like_repo_id # noqa: E402 from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_BYTE, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, TOKEN_TYPE_UNKNOWN, + add_general_identity, encode_for_gguf, gguf_name, reference_dtype_for, @@ -409,7 +410,7 @@ def compute_size_label(total_params: int) -> str: # --------------------------------------------------------------------------- -def convert(model_dir: Path, out_path: Path, variant: str) -> None: +def convert(model_dir: Path, out_path: Path, variant: str, repo_id: str | None = None) -> None: config_path = model_dir / "config.json" gen_config_path = model_dir / "generation_config.json" preproc_path = model_dir / "preprocessor_config.json" @@ -461,13 +462,30 @@ def convert(model_dir: Path, out_path: Path, variant: str) -> None: print(f"Total params: {total:,} -> size_label={size_label}") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "moonshine_streaming") + writer = gguf_writer(str(out_path), "moonshine_streaming") # ---- general.* ---- - writer.add_string("general.basename", "moonshine_streaming") - writer.add_string("general.size_label", size_label) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array("general.languages", ["en"]) + _DISPLAY_NAMES = { + "moonshine-streaming-tiny": "Moonshine Streaming Tiny", + "moonshine-streaming-small": "Moonshine Streaming Small", + "moonshine-streaming-medium": "Moonshine Streaming Medium", + } + if variant not in _DISPLAY_NAMES: + raise ValueError(f"unknown moonshine_streaming variant slug: {variant!r}") + add_general_identity( + writer, + name=_DISPLAY_NAMES[variant], + basename="moonshine_streaming", + size_label=size_label, + file_type=int(REFERENCE_FILE_TYPE), + languages=["en"], + author="Useful Sensors", + organization="UsefulSensors", + license="mit", + license_name="MIT License", + license_link="https://opensource.org/license/mit", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) # ---- stt.variant ---- writer.add_string("stt.variant", variant) @@ -658,29 +676,6 @@ def add(src_name: str, dst_name: str, transform=passthrough) -> None: print(f"Done. Wrote {out_path} ({out_path.stat().st_size / (1024 * 1024):.1f} MB)") -def _looks_like_repo_id(s: str) -> bool: - return "/" in s and not Path(s).exists() - - -def _download_snapshot(repo_id: str, revision: str | None) -> Path: - slug = slug_from_repo_id(repo_id) - models_root = os.environ.get("TRANSCRIBE_MODELS_DIR") - local_dir = Path(models_root) / slug if models_root else None - if local_dir is not None: - local_dir.mkdir(parents=True, exist_ok=True) - if revision: - print(f"Downloading {repo_id}@{revision} from Hugging Face...") - else: - print(f"Downloading {repo_id} from Hugging Face " - f"(no revision pin; reproducibility depends on upstream)...") - resolved = snapshot_download( - repo_id=repo_id, - revision=revision, - local_dir=str(local_dir) if local_dir is not None else None, - ) - return Path(resolved) - - def main(argv: list[str]) -> int: p = argparse.ArgumentParser( description="Convert a Moonshine Streaming checkpoint to a reference-dtype GGUF.", @@ -698,9 +693,9 @@ def main(argv: list[str]) -> int: help="stt.variant string (default: derived from slug)") args = p.parse_args(argv[1:]) - if _looks_like_repo_id(args.model): + if looks_like_repo_id(args.model): repo_id = args.repo_id or args.model - model_dir = _download_snapshot(args.model, args.revision) + model_dir = download_snapshot(args.model, args.revision) else: model_dir = Path(args.model) if not model_dir.is_dir(): @@ -737,7 +732,7 @@ def main(argv: list[str]) -> int: break variant = stripped - convert(model_dir, out_path, variant) + convert(model_dir, out_path, variant, repo_id=repo_id) return 0 diff --git a/scripts/convert-parakeet.py b/scripts/convert-parakeet.py index 5f32fc0b..48831cc0 100644 --- a/scripts/convert-parakeet.py +++ b/scripts/convert-parakeet.py @@ -69,15 +69,17 @@ from pathlib import Path import numpy as np -from gguf import GGUFWriter, LlamaFileType +from gguf import LlamaFileType sys.path.insert(0, str(Path(__file__).resolve().parent)) from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_BYTE, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, TOKEN_TYPE_UNKNOWN, TOKEN_TYPE_UNUSED, + add_general_identity, canonicalize_normalize, gguf_name, safe_id, @@ -122,37 +124,50 @@ # v2: 0.6B English-only TDT. "parakeet-tdt-0.6b-v2": { "variant": "tdt-0.6b-v2", + "display_name": "Parakeet TDT 0.6B v2", "version": "v2", "size_label": "0.6B", "head_kind": "tdt", "expected_vocab_size": 1024, "languages": ["en"], "lang_detect": False, + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", + "license_link": "https://creativecommons.org/licenses/by/4.0/", }, # v3: 0.6B multilingual TDT. "parakeet-tdt-0.6b-v3": { "variant": "tdt-0.6b-v3", + "display_name": "Parakeet TDT 0.6B v3", "version": "v3", "size_label": "0.6B", "head_kind": "tdt", "expected_vocab_size": 8192, "languages": V3_LANGUAGES, "lang_detect": True, + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", + "license_link": "https://creativecommons.org/licenses/by/4.0/", }, # 1.1B English-only TDT. Predates the v2/v3 split; the upstream # repo carries no version suffix, so general.version is "v1". "parakeet-tdt-1.1b": { "variant": "tdt-1.1b", + "display_name": "Parakeet TDT 1.1B", "version": "v1", "size_label": "1.1B", "head_kind": "tdt", "expected_vocab_size": 1024, "languages": ["en"], "lang_detect": False, + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", + "license_link": "https://creativecommons.org/licenses/by/4.0/", }, # 0.6B English RNNT. Pure transducer, no TDT durations head. "parakeet-rnnt-0.6b": { "variant": "rnnt-0.6b", + "display_name": "Parakeet RNN-T 0.6B", "version": "v1", "size_label": "0.6B", "basename": "parakeet-rnnt", @@ -160,10 +175,14 @@ "expected_vocab_size": 1024, "languages": ["en"], "lang_detect": False, + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", + "license_link": "https://creativecommons.org/licenses/by/4.0/", }, # 1.1B English RNNT. "parakeet-rnnt-1.1b": { "variant": "rnnt-1.1b", + "display_name": "Parakeet RNN-T 1.1B", "version": "v1", "size_label": "1.1B", "basename": "parakeet-rnnt", @@ -171,6 +190,9 @@ "expected_vocab_size": 1024, "languages": ["en"], "lang_detect": False, + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", + "license_link": "https://creativecommons.org/licenses/by/4.0/", }, # 0.6B English unified offline+streaming RNNT. Same FastConformer # encoder weights serve both modes — offline runs with full @@ -181,6 +203,7 @@ # default and the streaming menu. "parakeet-unified-en-0.6b": { "variant": "unified-en-0.6b", + "display_name": "Parakeet Unified EN 0.6B", "version": "v1", "size_label": "0.6B", "basename": "parakeet-rnnt", @@ -188,12 +211,16 @@ "expected_vocab_size": 1024, "languages": ["en"], "lang_detect": False, + "license": "other", + "license_name": "nvidia-open-model-license", + "license_link": "https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-open-model-license/", }, # 0.6B English CTC. No predictor, no joint — encoder feeds a # single 1x1 conv (decoder.decoder_layers.0) projecting d_model # to vocab+1. "parakeet-ctc-0.6b": { "variant": "ctc-0.6b", + "display_name": "Parakeet CTC 0.6B", "version": "v1", "size_label": "0.6B", "basename": "parakeet-ctc", @@ -201,10 +228,14 @@ "expected_vocab_size": 1024, "languages": ["en"], "lang_detect": False, + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", + "license_link": "https://creativecommons.org/licenses/by/4.0/", }, # 1.1B English CTC. "parakeet-ctc-1.1b": { "variant": "ctc-1.1b", + "display_name": "Parakeet CTC 1.1B", "version": "v1", "size_label": "1.1B", "basename": "parakeet-ctc", @@ -212,6 +243,9 @@ "expected_vocab_size": 1024, "languages": ["en"], "lang_detect": False, + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", + "license_link": "https://creativecommons.org/licenses/by/4.0/", }, # 110M English hybrid TDT+CTC. Shipped as TDT-only at runtime # per the family-level Open-decisions #1 ("the pure ctc-* variants @@ -221,12 +255,16 @@ # the existing (Stage 4) TDT codepath. "parakeet-tdt_ctc-110m": { "variant": "tdt_ctc-110m", + "display_name": "Parakeet TDT-CTC 110M", "version": "v1", "size_label": "110M", "head_kind": "tdt", "expected_vocab_size": 1024, "languages": ["en"], "lang_detect": False, + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", + "license_link": "https://creativecommons.org/licenses/by/4.0/", }, # 1.1B English hybrid TDT+CTC. Same TDT-only shipping decision. # Marked `prefer_direct_load=True` so the converter bypasses @@ -234,6 +272,7 @@ # reads the cached .nemo archive in place. "parakeet-tdt_ctc-1.1b": { "variant": "tdt_ctc-1.1b", + "display_name": "Parakeet TDT-CTC 1.1B", "version": "v1", "size_label": "1.1B", "head_kind": "tdt", @@ -241,6 +280,9 @@ "languages": ["en"], "lang_detect": False, "prefer_direct_load": True, + "license": "cc-by-4.0", + "license_name": "Creative Commons Attribution 4.0", + "license_link": "https://creativecommons.org/licenses/by/4.0/", }, # 0.6B English cache-aware streaming RNNT. FastConformer encoder # with att_context_style='chunked_limited' and causal depthwise @@ -252,6 +294,7 @@ # the existing predictor/joint code path applies. "nemotron-speech-streaming-en-0.6b": { "variant": "nemotron-speech-streaming-en-0.6b", + "display_name": "Nemotron Speech Streaming EN", "version": "v1", "size_label": "0.6B", "basename": "parakeet-rnnt", @@ -259,6 +302,9 @@ "expected_vocab_size": 1024, "languages": ["en"], "lang_detect": False, + "license": "other", + "license_name": "nvidia-open-model-license", + "license_link": "https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-open-model-license/", }, # 0.6B multilingual cache-aware streaming RNN-T. Same FastConformer # encoder as the English predecessor (24L / d_model=1024 / 8h / @@ -273,6 +319,7 @@ # holds. "nemotron-3.5-asr-streaming-0.6b": { "variant": "nemotron-3.5-asr-streaming-0.6b", + "display_name": "Nemotron Streaming 3.5", "version": "v1", "size_label": "0.6B", "basename": "parakeet-rnnt", @@ -294,6 +341,9 @@ ], "lang_detect": True, "has_prompt": True, + "license": "other", + "license_name": "openmdw-1.1", + "license_link": "https://openmdw.ai/license/1-1/", }, } @@ -1172,7 +1222,7 @@ def tensor_to_fp32_numpy(t) -> np.ndarray: # --------------------------------------------------------------------------- -def convert(model_spec: str, out_path: Path) -> None: +def convert(model_spec: str, out_path: Path, repo_id: str | None = None) -> None: from omegaconf import OmegaConf print(f"Output dtype: {REFERENCE_DTYPE_LABEL} (source/reference dtype)") @@ -1315,14 +1365,24 @@ def convert(model_spec: str, out_path: Path) -> None: print(f"Encoder use_bias: {hp['enc_use_bias']}") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "parakeet") + writer = gguf_writer(str(out_path), "parakeet") # ----- general.* metadata ----- - writer.add_string("general.basename", profile.get("basename", "parakeet-tdt")) - writer.add_string("general.size_label", profile["size_label"]) - writer.add_string("general.version", profile["version"]) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array ("general.languages", profile["languages"]) + add_general_identity( + writer, + name=profile["display_name"], + basename=profile.get("basename", "parakeet-tdt"), + size_label=profile["size_label"], + version=profile["version"], + file_type=REFERENCE_FILE_TYPE, + languages=profile["languages"], + author="NVIDIA", + organization="nvidia", + license=profile["license"], + license_name=profile["license_name"], + license_link=profile["license_link"], + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) # ----- stt.variant + capability KV ----- writer.add_string("stt.variant", profile["variant"]) @@ -1701,7 +1761,10 @@ def main(argv: list[str]) -> int: out_path = REPO_ROOT / "models" / slug / gguf_name(slug, REFERENCE_DTYPE_LABEL) out_path.parent.mkdir(parents=True, exist_ok=True) - convert(args.model, out_path) + repo_id = args.repo_id + if repo_id is None and "/" in args.model and not Path(args.model).exists(): + repo_id = args.model + convert(args.model, out_path, repo_id=repo_id) return 0 diff --git a/scripts/convert-qwen3_asr.py b/scripts/convert-qwen3_asr.py index 25f17085..16f9abb0 100755 --- a/scripts/convert-qwen3_asr.py +++ b/scripts/convert-qwen3_asr.py @@ -74,14 +74,12 @@ import argparse import json -import os import sys from pathlib import Path import numpy as np import torch -from gguf import GGMLQuantizationType, GGUFWriter, LlamaFileType -from huggingface_hub import snapshot_download +from gguf import GGMLQuantizationType, LlamaFileType from contextlib import ExitStack from safetensors import safe_open @@ -131,9 +129,12 @@ def get_tensor(self, name: str): return self._handles[self._shard_for[name]].get_tensor(name) sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib.hf_source import download_snapshot, looks_like_repo_id # noqa: E402 from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, + add_general_identity, encode_for_gguf, gguf_name, reference_dtype_for, @@ -465,7 +466,7 @@ def compute_size_label(total_params: int) -> str: # --------------------------------------------------------------------------- -def convert(model_dir: Path, out_path: Path, variant: str) -> None: +def convert(model_dir: Path, out_path: Path, variant: str, repo_id: str | None = None) -> None: print(f"Output dtype: {REFERENCE_DTYPE_LABEL} (source/reference dtype)") config_path = model_dir / "config.json" @@ -525,13 +526,26 @@ def convert(model_dir: Path, out_path: Path, variant: str) -> None: print(f"Total params (deduplicated): {total:,} -> size_label={size_label}") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "qwen3_asr") + writer = gguf_writer(str(out_path), "qwen3_asr") # ---- general.* ---- - writer.add_string("general.basename", "qwen3-asr") - writer.add_string("general.size_label", size_label) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array("general.languages", hp["languages"]) + add_general_identity( + writer, + name={ + "qwen3-asr-0.6b": "Qwen3-ASR 0.6B", + "qwen3-asr-1.7b": "Qwen3-ASR 1.7B", + }[variant], + basename="qwen3-asr", + size_label=size_label, + file_type=REFERENCE_FILE_TYPE, + languages=hp["languages"], + author="Alibaba Qwen Team", + organization="Qwen", + license="apache-2.0", + license_name="Apache License 2.0", + license_link="https://www.apache.org/licenses/LICENSE-2.0", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) # ---- stt.variant ---- writer.add_string("stt.variant", variant) @@ -752,29 +766,6 @@ def add(src_name: str, dst_name: str, transform=passthrough) -> None: print(f"Done. Wrote {out_path} ({out_path.stat().st_size / (1024 * 1024):.1f} MB)") -def _looks_like_repo_id(s: str) -> bool: - return "/" in s and not Path(s).exists() - - -def _download_snapshot(repo_id: str, revision: str | None) -> Path: - slug = slug_from_repo_id(repo_id) - models_root = os.environ.get("TRANSCRIBE_MODELS_DIR") - local_dir = Path(models_root) / slug if models_root else None - if local_dir is not None: - local_dir.mkdir(parents=True, exist_ok=True) - if revision: - print(f"Downloading {repo_id}@{revision} from Hugging Face...") - else: - print(f"Downloading {repo_id} from Hugging Face " - f"(no revision pin; reproducibility depends on upstream)...") - resolved = snapshot_download( - repo_id=repo_id, - revision=revision, - local_dir=str(local_dir) if local_dir is not None else None, - ) - return Path(resolved) - - def main(argv: list[str]) -> int: p = argparse.ArgumentParser( description="Convert a Qwen3-ASR checkpoint to a BF16 accuracy GGUF.", @@ -796,9 +787,9 @@ def main(argv: list[str]) -> int: help="stt.variant string (default: derived from slug)") args = p.parse_args(argv[1:]) - if _looks_like_repo_id(args.model): + if looks_like_repo_id(args.model): repo_id = args.repo_id or args.model - model_dir = _download_snapshot(args.model, args.revision) + model_dir = download_snapshot(args.model, args.revision) else: model_dir = Path(args.model) if not model_dir.is_dir(): @@ -841,7 +832,7 @@ def main(argv: list[str]) -> int: break variant = stripped - convert(model_dir, out_path, variant) + convert(model_dir, out_path, variant, repo_id=repo_id) return 0 diff --git a/scripts/convert-sensevoice.py b/scripts/convert-sensevoice.py index a9771405..58fbca1f 100644 --- a/scripts/convert-sensevoice.py +++ b/scripts/convert-sensevoice.py @@ -91,25 +91,26 @@ import argparse import json -import os import sys from pathlib import Path import numpy as np import torch import yaml -from gguf import GGMLQuantizationType, GGUFWriter, LlamaFileType -from huggingface_hub import snapshot_download +from gguf import GGMLQuantizationType, LlamaFileType import sentencepiece as spm sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib.hf_source import download_snapshot, looks_like_repo_id # noqa: E402 from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_BYTE, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, TOKEN_TYPE_UNKNOWN, TOKEN_TYPE_UNUSED, + add_general_identity, encode_for_gguf, gguf_name, reference_dtype_for, @@ -363,7 +364,7 @@ def compute_size_label(total_params: int) -> str: # --------------------------------------------------------------------------- -def convert(model_dir: Path, out_path: Path, variant: str) -> None: +def convert(model_dir: Path, out_path: Path, variant: str, repo_id: str | None = None) -> None: print(f"Output dtype: {REFERENCE_DTYPE_LABEL} (source/reference dtype)") config_yaml = model_dir / "config.yaml" @@ -424,7 +425,7 @@ def convert(model_dir: Path, out_path: Path, variant: str) -> None: print(f"Total params: {total_params:,} -> size_label={size_label}") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "sensevoice") + writer = gguf_writer(str(out_path), "sensevoice") # ----- general.* ----- # FunASR Model Open Source License Agreement v1.1 attribution @@ -432,34 +433,42 @@ def convert(model_dir: Path, out_path: Path, variant: str) -> None: # and author information and retain relevant model names". Bake the # canonical attribution into the GGUF KV so downstream consumers # see source + author + model names without reading external docs. - writer.add_string("general.name", "SenseVoiceSmall") - writer.add_string("general.basename", variant) - writer.add_string("general.size_label", size_label) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array ("general.languages", hp["languages"]) - writer.add_string("general.author", "Alibaba Group / FunAudioLLM") - writer.add_string("general.organization", "FunAudioLLM") - writer.add_string("general.license", "FunASR-Model-License-1.1") - writer.add_string("general.license.link", - "https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE") - writer.add_string("general.url", - "https://huggingface.co/FunAudioLLM/SenseVoiceSmall") - writer.add_string("general.source.url", - "https://github.com/modelscope/FunASR") - writer.add_array("general.tags", [ - "asr", - "speech-recognition", - "encoder-ctc", - "SenseVoiceSmall", - "SenseVoiceEncoderSmall", - ]) - writer.add_string("general.description", - "SenseVoiceSmall (Alibaba FunAudioLLM): non-AR " - "CTC ASR with multilingual + emotion/event/ITN " - "label heads. Converted from FunAudioLLM/" - "SenseVoiceSmall; see " - "https://github.com/modelscope/FunASR/blob/main/" - "MODEL_LICENSE for FunASR redistribution terms.") + add_general_identity( + writer, + name="SenseVoice Small", + basename=variant, + size_label=size_label, + file_type=int(REFERENCE_FILE_TYPE), + languages=hp["languages"], + author="Alibaba Group / FunAudioLLM", + organization="FunAudioLLM", + # FunASR Model Open Source License Agreement v1.1. NOT an SPDX + # identifier and NOT Apache-2.0 — SenseVoiceSmall ships under + # FunASR's own model license (unlike funasr_nano, which is genuinely + # apache-2.0). Keep the canonical license string so downstream + # consumers see the real license, and retain the MODEL_LICENSE link + # the agreement's attribution clause (2.2) requires. + license="FunASR-Model-License-1.1", + license_link="https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + url="https://huggingface.co/FunAudioLLM/SenseVoiceSmall", + source_url="https://github.com/modelscope/FunASR", + tags=[ + "asr", + "speech-recognition", + "encoder-ctc", + "SenseVoiceSmall", + "SenseVoiceEncoderSmall", + ], + description=( + "SenseVoiceSmall (Alibaba FunAudioLLM): non-AR " + "CTC ASR with multilingual + emotion/event/ITN " + "label heads. Converted from FunAudioLLM/" + "SenseVoiceSmall; see " + "https://github.com/modelscope/FunASR/blob/main/" + "MODEL_LICENSE for FunASR redistribution terms." + ), + ) # ----- stt.variant + capabilities ----- writer.add_string("stt.variant", variant) @@ -624,29 +633,6 @@ def add(src_name: str, dst_name: str) -> None: # --------------------------------------------------------------------------- -def _looks_like_repo_id(s: str) -> bool: - return "/" in s and not Path(s).exists() - - -def _download_snapshot(repo_id: str, revision: str | None) -> Path: - slug = slug_from_repo_id(repo_id) - models_root = os.environ.get("TRANSCRIBE_MODELS_DIR") - local_dir = Path(models_root) / slug if models_root else None - if local_dir is not None: - local_dir.mkdir(parents=True, exist_ok=True) - if revision: - print(f"Downloading {repo_id}@{revision} from Hugging Face...") - else: - print(f"Downloading {repo_id} from Hugging Face " - f"(no revision pin; reproducibility depends on upstream)...") - resolved = snapshot_download( - repo_id=repo_id, - revision=revision, - local_dir=str(local_dir) if local_dir is not None else None, - ) - return Path(resolved) - - # Map the upstream HF slug to the canonical kebab-case variant the rest # of the porting framework uses (manifest, build/validate dir, family # doc). Keep this explicit rather than camelCase-splitting — there is @@ -673,9 +659,9 @@ def main(argv: list[str]) -> int: help="stt.variant string (default: derived from slug)") args = p.parse_args(argv[1:]) - if _looks_like_repo_id(args.model): + if looks_like_repo_id(args.model): repo_id = args.repo_id or args.model - model_dir = _download_snapshot(args.model, args.revision) + model_dir = download_snapshot(args.model, args.revision) else: model_dir = Path(args.model) if not model_dir.is_dir(): @@ -705,7 +691,7 @@ def main(argv: list[str]) -> int: out_path = REPO_ROOT / "models" / output_slug / gguf_name(output_slug, REFERENCE_DTYPE_LABEL) out_path.parent.mkdir(parents=True, exist_ok=True) - convert(model_dir, out_path, variant) + convert(model_dir, out_path, variant, repo_id=repo_id) return 0 diff --git a/scripts/convert-voxtral.py b/scripts/convert-voxtral.py index f55487e9..b381596c 100644 --- a/scripts/convert-voxtral.py +++ b/scripts/convert-voxtral.py @@ -35,21 +35,22 @@ import argparse import json -import os import sys from contextlib import ExitStack from pathlib import Path import numpy as np import torch -from gguf import GGMLQuantizationType, GGUFWriter, LlamaFileType -from huggingface_hub import snapshot_download +from gguf import GGMLQuantizationType, LlamaFileType from safetensors import safe_open sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib.hf_source import download_snapshot, looks_like_repo_id # noqa: E402 from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, + add_general_identity, encode_for_gguf, gguf_name, reference_dtype_for, @@ -367,7 +368,7 @@ def compute_size_label(total_params: int) -> str: # --------------------------------------------------------------------------- -def convert(model_dir: Path, out_path: Path, variant: str) -> None: +def convert(model_dir: Path, out_path: Path, variant: str, repo_id: str | None = None) -> None: print(f"Output dtype: {REFERENCE_DTYPE_LABEL} (source/reference dtype)") config = json.loads((model_dir / "config.json").read_text()) @@ -394,13 +395,31 @@ def convert(model_dir: Path, out_path: Path, variant: str) -> None: print(f"Total params: {total:,} -> size_label={size_label}") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "voxtral") + writer = gguf_writer(str(out_path), "voxtral") # ---- general.* ---- - writer.add_string("general.basename", "voxtral") - writer.add_string("general.size_label", size_label) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array("general.languages", hp["languages"]) + _VARIANT_TABLE = { + "voxtral-mini-3b-2507": ("Voxtral Mini 3B", "2507"), + "voxtral-small-24b-2507": ("Voxtral Small 24B", "2507"), + } + if variant not in _VARIANT_TABLE: + raise ValueError(f"unknown voxtral variant slug: {variant!r}") + _disp_name, _disp_version = _VARIANT_TABLE[variant] + add_general_identity( + writer, + name=_disp_name, + basename="voxtral", + version=_disp_version, + size_label=size_label, + file_type=int(REFERENCE_FILE_TYPE), + languages=hp["languages"], + author="Mistral AI", + organization="mistralai", + license="apache-2.0", + license_name="Apache License 2.0", + license_link="https://www.apache.org/licenses/LICENSE-2.0", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) writer.add_string("stt.variant", variant) # ---- stt.capability.* ---- @@ -408,6 +427,7 @@ def convert(model_dir: Path, out_path: Path, variant: str) -> None: # translation are both in scope for this port (user-signed). writer.add_bool("stt.capability.lang_detect", True) writer.add_bool("stt.capability.translate", True) + writer.add_array("stt.translation.target_languages", hp["languages"]) # ---- tokenizer.ggml.* (Mistral tekken -> llama.cpp gpt2 BPE) ---- writer.add_string("tokenizer.ggml.model", "gpt2") @@ -569,23 +589,6 @@ def add(src_name: str, dst_name: str) -> None: # --------------------------------------------------------------------------- -def _looks_like_repo_id(s: str) -> bool: - return "/" in s and not Path(s).exists() - - -def _download_snapshot(repo_id: str, revision: str | None) -> Path: - slug = slug_from_repo_id(repo_id) - models_root = os.environ.get("TRANSCRIBE_MODELS_DIR") - local_dir = Path(models_root) / slug if models_root else None - if local_dir is not None: - local_dir.mkdir(parents=True, exist_ok=True) - resolved = snapshot_download( - repo_id=repo_id, revision=revision, - local_dir=str(local_dir) if local_dir is not None else None, - ) - return Path(resolved) - - def main(argv: list[str]) -> int: p = argparse.ArgumentParser( description="Convert a Voxtral (2507) checkpoint to a BF16 reference GGUF.") @@ -600,9 +603,9 @@ def main(argv: list[str]) -> int: help="stt.variant string (default: derived from slug)") args = p.parse_args(argv[1:]) - if _looks_like_repo_id(args.model): + if looks_like_repo_id(args.model): repo_id = args.repo_id or args.model - model_dir = _download_snapshot(args.model, args.revision) + model_dir = download_snapshot(args.model, args.revision) else: model_dir = Path(args.model) if not model_dir.is_dir(): @@ -630,7 +633,7 @@ def main(argv: list[str]) -> int: variant = variant[: -len(q)] break - convert(model_dir, out_path, variant) + convert(model_dir, out_path, variant, repo_id=repo_id) return 0 diff --git a/scripts/convert-voxtral_realtime.py b/scripts/convert-voxtral_realtime.py index a1de5b3b..40811f92 100644 --- a/scripts/convert-voxtral_realtime.py +++ b/scripts/convert-voxtral_realtime.py @@ -45,19 +45,20 @@ import argparse import json import math -import os import sys from pathlib import Path import numpy as np import torch -from gguf import GGMLQuantizationType, GGUFWriter, LlamaFileType -from huggingface_hub import snapshot_download +from gguf import GGMLQuantizationType, LlamaFileType sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib.hf_source import download_snapshot, looks_like_repo_id # noqa: E402 from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, + add_general_identity, encode_for_gguf, gguf_name, reference_dtype_for, @@ -317,7 +318,7 @@ def compute_size_label(total_params: int) -> str: # --------------------------------------------------------------------------- -def convert(model_dir: Path, out_path: Path, variant: str) -> None: +def convert(model_dir: Path, out_path: Path, variant: str, repo_id: str | None = None) -> None: from transformers import VoxtralRealtimeForConditionalGeneration print(f"Output dtype: {REFERENCE_DTYPE_LABEL} (source/reference dtype)") @@ -354,13 +355,24 @@ def convert(model_dir: Path, out_path: Path, variant: str) -> None: print(f"Total params: {total:,} -> size_label={size_label}") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "voxtral_realtime") + writer = gguf_writer(str(out_path), "voxtral_realtime") # ---- general.* ---- - writer.add_string("general.basename", "voxtral_realtime") - writer.add_string("general.size_label", size_label) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array("general.languages", hp["languages"]) + add_general_identity( + writer, + name="Voxtral Mini 4B Realtime", + basename="voxtral_realtime", + version="2602", + size_label=size_label, + file_type=int(REFERENCE_FILE_TYPE), + languages=hp["languages"], + author="Mistral AI", + organization="mistralai", + license="apache-2.0", + license_name="Apache License 2.0", + license_link="https://www.apache.org/licenses/LICENSE-2.0", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) writer.add_string("stt.variant", variant) # ---- stt.capability.* ---- @@ -534,23 +546,6 @@ def add(src_name: str, dst_name: str) -> None: # --------------------------------------------------------------------------- -def _looks_like_repo_id(s: str) -> bool: - return "/" in s and not Path(s).exists() - - -def _download_snapshot(repo_id: str, revision: str | None) -> Path: - slug = slug_from_repo_id(repo_id) - models_root = os.environ.get("TRANSCRIBE_MODELS_DIR") - local_dir = Path(models_root) / slug if models_root else None - if local_dir is not None: - local_dir.mkdir(parents=True, exist_ok=True) - resolved = snapshot_download( - repo_id=repo_id, revision=revision, - local_dir=str(local_dir) if local_dir is not None else None, - ) - return Path(resolved) - - def main(argv: list[str]) -> int: p = argparse.ArgumentParser( description="Convert a Voxtral Realtime (2602) checkpoint to a BF16 reference GGUF.") @@ -565,9 +560,9 @@ def main(argv: list[str]) -> int: help="stt.variant string (default: derived from slug)") args = p.parse_args(argv[1:]) - if _looks_like_repo_id(args.model): + if looks_like_repo_id(args.model): repo_id = args.repo_id or args.model - model_dir = _download_snapshot(args.model, args.revision) + model_dir = download_snapshot(args.model, args.revision) else: model_dir = Path(args.model) if not model_dir.is_dir(): @@ -595,7 +590,7 @@ def main(argv: list[str]) -> int: variant = variant[: -len(q)] break - convert(model_dir, out_path, variant) + convert(model_dir, out_path, variant, repo_id=repo_id) return 0 diff --git a/scripts/convert-whisper.py b/scripts/convert-whisper.py index c004f0ec..cf3327b1 100644 --- a/scripts/convert-whisper.py +++ b/scripts/convert-whisper.py @@ -125,20 +125,21 @@ import argparse import json -import os import sys from pathlib import Path import numpy as np import torch -from gguf import GGMLQuantizationType, GGUFWriter, LlamaFileType -from huggingface_hub import snapshot_download +from gguf import GGMLQuantizationType, LlamaFileType from safetensors import safe_open sys.path.insert(0, str(Path(__file__).resolve().parent)) +from lib.hf_source import download_snapshot, looks_like_repo_id # noqa: E402 from lib.gguf_common import ( # noqa: E402 + gguf_writer, TOKEN_TYPE_CONTROL, TOKEN_TYPE_NORMAL, + add_general_identity, encode_for_gguf, gguf_name, reference_dtype_for, @@ -440,12 +441,34 @@ def compute_size_label(total_params: int) -> str: return f"{total_params / 1_000:.0f}K" +# --------------------------------------------------------------------------- +# Display names +# --------------------------------------------------------------------------- +# Friendly general.name per variant slug (the variant carries the version, +# so general.version is left unset). + +VARIANT_DISPLAY_NAMES: dict[str, str] = { + "whisper-tiny": "Whisper Tiny", + "whisper-tiny.en": "Whisper Tiny (English)", + "whisper-base": "Whisper Base", + "whisper-base.en": "Whisper Base (English)", + "whisper-small": "Whisper Small", + "whisper-small.en": "Whisper Small (English)", + "whisper-medium": "Whisper Medium", + "whisper-medium.en": "Whisper Medium (English)", + "whisper-large": "Whisper Large", + "whisper-large-v2": "Whisper Large v2", + "whisper-large-v3": "Whisper Large v3", + "whisper-large-v3-turbo": "Whisper Large v3 Turbo", +} + + # --------------------------------------------------------------------------- # Main converter # --------------------------------------------------------------------------- -def convert(model_dir: Path, out_path: Path, variant: str) -> None: +def convert(model_dir: Path, out_path: Path, variant: str, repo_id: str | None = None) -> None: config_path = model_dir / "config.json" gen_config_path = model_dir / "generation_config.json" preproc_path = model_dir / "preprocessor_config.json" @@ -492,13 +515,28 @@ def convert(model_dir: Path, out_path: Path, variant: str) -> None: print(f"Total params: {total:,} -> size_label={size_label}") print(f"Writing GGUF to {out_path}") - writer = GGUFWriter(str(out_path), "whisper") + writer = gguf_writer(str(out_path), "whisper") # ---- general.* ---- - writer.add_string("general.basename", "whisper") - writer.add_string("general.size_label", size_label) - writer.add_uint32("general.file_type", int(REFERENCE_FILE_TYPE)) - writer.add_array("general.languages", hp["languages"]) + if variant not in VARIANT_DISPLAY_NAMES: + raise ValueError( + f"unknown whisper variant slug: {variant!r}; " + f"add it to VARIANT_DISPLAY_NAMES" + ) + add_general_identity( + writer, + name=VARIANT_DISPLAY_NAMES[variant], + basename="whisper", + size_label=size_label, + file_type=REFERENCE_FILE_TYPE, + languages=hp["languages"], + author="OpenAI", + organization="openai", + license="apache-2.0", + license_name="Apache License 2.0", + license_link="https://www.apache.org/licenses/LICENSE-2.0", + repo_url=(f"https://huggingface.co/{repo_id}" if repo_id else None), + ) # ---- stt.variant ---- writer.add_string("stt.variant", variant) @@ -512,6 +550,8 @@ def convert(model_dir: Path, out_path: Path, variant: str) -> None: writer.add_bool("stt.capability.lang_detect", is_multilingual) writer.add_bool("stt.capability.translate", is_multilingual) writer.add_bool("stt.capability.timestamps", True) + if is_multilingual: + writer.add_array("stt.translation.target_languages", ["en"]) # ---- tokenizer.ggml.* (llama.cpp "gpt2" byte-level BPE) ---- # tokenizer.ggml.pre="gpt2" selects the original GPT-2 @@ -736,29 +776,6 @@ def add(src_name: str, dst_name: str, transform=passthrough) -> None: print(f"Done. Wrote {out_path} ({out_path.stat().st_size / (1024 * 1024):.1f} MB)") -def _looks_like_repo_id(s: str) -> bool: - return "/" in s and not Path(s).exists() - - -def _download_snapshot(repo_id: str, revision: str | None) -> Path: - slug = slug_from_repo_id(repo_id) - models_root = os.environ.get("TRANSCRIBE_MODELS_DIR") - local_dir = Path(models_root) / slug if models_root else None - if local_dir is not None: - local_dir.mkdir(parents=True, exist_ok=True) - if revision: - print(f"Downloading {repo_id}@{revision} from Hugging Face...") - else: - print(f"Downloading {repo_id} from Hugging Face " - f"(no revision pin; reproducibility depends on upstream)...") - resolved = snapshot_download( - repo_id=repo_id, - revision=revision, - local_dir=str(local_dir) if local_dir is not None else None, - ) - return Path(resolved) - - def main(argv: list[str]) -> int: p = argparse.ArgumentParser( description="Convert a Whisper checkpoint to an F32 reference GGUF.", @@ -778,9 +795,9 @@ def main(argv: list[str]) -> int: help="stt.variant string (default: derived from slug)") args = p.parse_args(argv[1:]) - if _looks_like_repo_id(args.model): + if looks_like_repo_id(args.model): repo_id = args.repo_id or args.model - model_dir = _download_snapshot(args.model, args.revision) + model_dir = download_snapshot(args.model, args.revision) else: model_dir = Path(args.model) if not model_dir.is_dir(): @@ -819,7 +836,7 @@ def main(argv: list[str]) -> int: break variant = stripped - convert(model_dir, out_path, variant) + convert(model_dir, out_path, variant, repo_id=repo_id) return 0 diff --git a/scripts/hf_cards/granite-speech-4.1-2b-plus.yaml b/scripts/hf_cards/granite-speech-4.1-2b-plus.yaml index 28ec1684..2dbf4776 100644 --- a/scripts/hf_cards/granite-speech-4.1-2b-plus.yaml +++ b/scripts/hf_cards/granite-speech-4.1-2b-plus.yaml @@ -44,9 +44,8 @@ summary: | the lm_head. Takes a 16 kHz mono WAV and produces a transcript, optionally interleaved with `[SS:N]` word-timestamp markers. Transcribes English, French, German, Spanish, and Portuguese (no Japanese on this variant). - Translates between English and each of those four other languages in - either direction (en ↔ fr, en ↔ de, en ↔ es, en ↔ pt) — always via - English, no direct fr↔de etc. + This variant is transcription-only: unlike the base granite-speech-4.1-2b, + it does not perform speech translation. default_quant_index: 2 # Q8_0 diff --git a/scripts/intake.py b/scripts/intake.py index a7bf3cea..637b6bca 100755 --- a/scripts/intake.py +++ b/scripts/intake.py @@ -311,7 +311,6 @@ def extract_capabilities(config, tok_cfg, gen_cfg) -> dict[str, Any]: "translation": None, "timestamps": [], "streaming": None, - "voice_activity_detection": None, "speaker_diarization": None, } diff --git a/scripts/lib/gguf_common.py b/scripts/lib/gguf_common.py index d829a12f..6b6b60d1 100644 --- a/scripts/lib/gguf_common.py +++ b/scripts/lib/gguf_common.py @@ -39,6 +39,103 @@ def gguf_name(slug: str, quant: str) -> str: return f"{slug}-{quant.upper()}.gguf" +def add_general_identity( + writer: gguf.GGUFWriter, + *, + name: str, + basename: str, + size_label: str | None = None, + file_type: GGMLQuantizationType | int | None = None, + languages: list[str] | None = None, + author: str | None = None, + organization: str | None = None, + version: str | None = None, + license: str | None = None, + license_name: str | None = None, + license_link: str | None = None, + repo_url: str | None = None, + url: str | None = None, + source_url: str | None = None, + description: str | None = None, + tags: list[str] | None = None, +) -> None: + """Write the conventional `general.*` identity block for a converter. + + Centralises the GGUF metadata keys llama.cpp / ggml tooling expects so + every transcribe.cpp GGUF carries a consistent, human-friendly identity + instead of a bare slug. Keys map 1:1 onto the llama.cpp `Keys.General` + namespace (gguf-py/gguf/constants.py), so any inspector built for + llama.cpp or whisper.cpp reads them without surprises. + + `general.architecture` is NOT written here — the GGUFWriter constructor + emits it automatically from its `arch` argument. + + Required (every GGUF should carry these): + name friendly display name, e.g. "Parakeet TDT 0.6B v3". + This is the headline string; set it explicitly per + variant rather than auto-composing from basename. + basename family slug, e.g. "parakeet-tdt". + + Recommended / optional (write what is known; None is skipped, leaving + the KV absent — pass exactly what the converter already emitted so the + existing key footprint is preserved): + size_label parameter-count class, e.g. "0.6B" (compute_size_label). + file_type reference dtype enum (int(REFERENCE_FILE_TYPE)). + languages BCP-47 / ISO-639 codes the model supports. + author creating lab/company, e.g. "NVIDIA", "OpenAI". + organization upstream HF org, e.g. "nvidia", "openai". + version model version string, e.g. "v3", "2507". + license SPDX expression, e.g. "apache-2.0", "cc-by-4.0". + license_name human-friendly license name. + license_link URL to the full license text. + repo_url canonical upstream repo (HF model page is fine). + url homepage / paper / release page. + source_url original project homepage when converted from another + format (provenance; e.g. an upstream GitHub repo). + description one-paragraph free-form description. + tags search/classification tags. + """ + if not name: + raise ValueError("general.name is required") + if not basename: + raise ValueError("general.basename is required") + + writer.add_string("general.name", name) + writer.add_string("general.basename", basename) + if version is not None: + writer.add_string("general.version", version) + if size_label is not None: + writer.add_string("general.size_label", size_label) + + if author is not None: + writer.add_string("general.author", author) + if organization is not None: + writer.add_string("general.organization", organization) + + if license is not None: + writer.add_string("general.license", license) + if license_name is not None: + writer.add_string("general.license.name", license_name) + if license_link is not None: + writer.add_string("general.license.link", license_link) + + if repo_url is not None: + writer.add_string("general.repo_url", repo_url) + if url is not None: + writer.add_string("general.url", url) + if source_url is not None: + writer.add_string("general.source.url", source_url) + if description is not None: + writer.add_string("general.description", description) + + if file_type is not None: + writer.add_uint32("general.file_type", int(file_type)) + if languages is not None: + writer.add_array("general.languages", languages) + if tags is not None: + writer.add_array("general.tags", tags) + + # llama.cpp / whisper.cpp tokenizer.ggml.token_type values. We follow # the same conventions so an inspector built for either project can # read our GGUFs without surprises. @@ -184,3 +281,66 @@ def canonicalize_normalize(raw) -> str: f"add an alias entry in scripts/lib/gguf_common.py " f"or update the intake schema enum." ) + + +# Large array / blob KVs, in canonical trailer order. These are relocated to the +# end of the KV section so range-read consumers can fetch the small scalar +# metadata (general.*, stt.*, tokenizer identity) without pulling the multi-MB +# tokenizer tables. GGUF has no KV offset index — readers parse KVs sequentially +# — so anything a remote consumer wants cheaply must precede these. +BULK_KV_KEYS = ( + "tokenizer.ggml.tokens", + "tokenizer.ggml.scores", + "tokenizer.ggml.token_type", + "tokenizer.ggml.merges", + "tokenizer.chat_template", +) + + +def move_bulk_metadata_last(writer) -> list[str]: + """Move the large tokenizer KVs to the end of every split's KV section. + + The internal mechanism behind `gguf_writer()` — converters should build + their writer via that factory rather than calling this directly, so the + streaming-friendly layout is automatic and cannot be forgotten. Keeps the + small, range-read-friendly scalar metadata first regardless of the order the + converter emitted KVs in. Returns the keys actually moved (for logging and + tests); a no-op when none of the bulk keys are present. + """ + kv_data = getattr(writer, "kv_data", None) + if not isinstance(kv_data, list) or not all(isinstance(s, dict) for s in kv_data): + raise RuntimeError( + "move_bulk_metadata_last: writer.kv_data is not the expected " + "list[dict] (gguf API drift?); refusing to reorder silently." + ) + moved: list[str] = [] + for shard in kv_data: + for key in BULK_KV_KEYS: + if key in shard: + shard[key] = shard.pop(key) # dict preserves insertion order + if key not in moved: + moved.append(key) + return moved + + +class _BulkLastGGUFWriter(gguf.GGUFWriter): + """GGUFWriter that relocates the bulk tokenizer KVs to the trailer at write + time. Hooked at write_kv_data_to_file (not write_header_to_file): the header + pass calls add_shard_kv_data(), which appends split.* scalar KVs, so we must + reorder *after* those are present but immediately before the KV dict is + serialized. The header only writes the KV count, which reordering leaves + unchanged.""" + + def write_kv_data_to_file(self) -> None: + move_bulk_metadata_last(self) + super().write_kv_data_to_file() + + +def gguf_writer(path, arch: str, **kwargs) -> gguf.GGUFWriter: + """Construct a GGUFWriter that automatically emits the bulk tokenizer KVs + (tokens / scores / token_type / merges / chat_template) in a trailer after + all scalar metadata, so remote consumers can range-read the small metadata + prefix without pulling the multi-MB tokenizer tables. Converters MUST build + their writer through this factory instead of gguf.GGUFWriter directly — that + is the single place the streaming layout is enforced.""" + return _BulkLastGGUFWriter(path, arch, **kwargs) diff --git a/scripts/lib/hf_source.py b/scripts/lib/hf_source.py new file mode 100644 index 00000000..60f6a0be --- /dev/null +++ b/scripts/lib/hf_source.py @@ -0,0 +1,69 @@ +"""Hugging Face source resolution shared across converters. + +Every HF-sourced converter needs the same three things: decide whether a +`--model` argument is a repo id or a local path, download a snapshot into +`$TRANSCRIBE_MODELS_DIR//` (falling back to the HF cache), and hand back +a local directory. This module is the single copy of that logic; before it, +`_download_snapshot` / `_looks_like_repo_id` / `hf_resolve` were duplicated +near-verbatim across a dozen converters. + +NeMo-sourced families (parakeet, canary) resolve checkpoints through +`ASRModel.from_pretrained` instead and do not use this module. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from lib.gguf_common import slug_from_repo_id + + +def looks_like_repo_id(s: str) -> bool: + """True for an `org/name` string with no matching local filesystem path. + + `expanduser()` so a real local checkpoint like `~/models/foo` is not + mistaken for an HF repo id (the pre-centralization copies omitted this). + """ + return "/" in s and not Path(s).expanduser().exists() + + +def download_snapshot(repo_id: str, revision: str | None = None) -> Path: + """Download an HF snapshot and return its local directory. + + When `$TRANSCRIBE_MODELS_DIR` is set the snapshot lands in + `//`; otherwise `snapshot_download` uses the default HF + cache and returns whatever local path it resolves to. Call this after the + caller has already decided `repo_id` is a repo (see `looks_like_repo_id`). + """ + from huggingface_hub import snapshot_download + + slug = slug_from_repo_id(repo_id) + models_root = os.environ.get("TRANSCRIBE_MODELS_DIR") + local_dir = Path(models_root) / slug if models_root else None + if local_dir is not None: + local_dir.mkdir(parents=True, exist_ok=True) + if revision: + print(f"Downloading {repo_id}@{revision} from Hugging Face...") + else: + print(f"Downloading {repo_id} from Hugging Face " + f"(no revision pin; reproducibility depends on upstream)...") + resolved = snapshot_download( + repo_id=repo_id, + revision=revision, + local_dir=str(local_dir) if local_dir is not None else None, + ) + return Path(resolved) + + +def resolve_model_dir(model_arg: str, revision: str | None = None) -> Path: + """Return a local directory for `model_arg`, downloading if needed. + + If `model_arg` is an existing directory it is returned as-is; otherwise it + is treated as an HF repo id and downloaded. This is the one-call form for + converters that do not need to distinguish the two cases at the call site. + """ + p = Path(model_arg).expanduser() + if p.is_dir(): + return p.resolve() + return download_snapshot(model_arg, revision) diff --git a/scripts/lib/test_gguf_writer.py b/scripts/lib/test_gguf_writer.py new file mode 100755 index 00000000..72a4aa72 --- /dev/null +++ b/scripts/lib/test_gguf_writer.py @@ -0,0 +1,135 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "gguf>=0.10", +# "numpy", +# ] +# /// +"""Unit tests for the streaming-friendly GGUF KV layout. + +Locks the invariant that range-read consumers depend on: the bulk tokenizer KVs +(tokens / scores / token_type / merges, chat_template) are written *after* every +scalar KV, so a remote reader can fetch the small metadata prefix without +pulling the multi-MB tokenizer tables. + +Run standalone (exit-code driven): uv run scripts/lib/test_gguf_writer.py +Or under pytest: pytest scripts/lib/test_gguf_writer.py +""" + +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +import numpy as np +from gguf import GGUFWriter +from gguf.gguf_reader import GGUFReader + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # repo scripts/ +from lib.gguf_common import ( # noqa: E402 + BULK_KV_KEYS, + gguf_writer, + move_bulk_metadata_last, +) + + +def _emit_mixed(writer) -> None: + """Emit KVs in the natural anti-pattern order: a bulk array lands in the + middle, with small scalar metadata both before and after it.""" + writer.add_string("general.name", "unit-test") + writer.add_bool("stt.capability.timestamps", True) + writer.add_string("tokenizer.ggml.model", "gpt2") # identity scalar + writer.add_array("tokenizer.ggml.tokens", ["a", "b", "c"]) # BULK, mid-stream + writer.add_array("tokenizer.ggml.merges", ["a b"]) # BULK, mid-stream + writer.add_uint32("stt.demo.encoder.n_layers", 4) # scalar AFTER the blob + writer.add_array("stt.demo.suppress_tokens", [1, 2, 3]) # small array, NOT bulk + + +def test_move_reorders_bulk_to_end(): + w = GGUFWriter(tempfile.mktemp(suffix=".gguf"), "demo") + _emit_mixed(w) + moved = move_bulk_metadata_last(w) + keys = list(w.kv_data[0].keys()) + bulk = {"tokenizer.ggml.tokens", "tokenizer.ggml.merges"} + assert set(moved) == bulk, moved + first_bulk = min(keys.index(k) for k in bulk) + last_scalar = max(keys.index(k) for k in keys if k not in bulk) + assert first_bulk > last_scalar, keys + # canonical trailer order (tokens before merges) and small array untouched + assert keys.index("tokenizer.ggml.tokens") < keys.index("tokenizer.ggml.merges") + assert keys.index("stt.demo.suppress_tokens") < first_bulk, keys + + +def test_move_is_noop_without_bulk_keys(): + w = GGUFWriter(tempfile.mktemp(suffix=".gguf"), "demo") + w.add_string("general.name", "x") + w.add_uint32("stt.demo.n", 1) + before = list(w.kv_data[0].keys()) + moved = move_bulk_metadata_last(w) + assert moved == [] + assert list(w.kv_data[0].keys()) == before + + +def test_split_scalars_precede_trailer(): + """Mirrors the real write order: GGUFWriter.write_header_to_file() appends + split.* scalars (via add_shard_kv_data) AFTER the converter's bulk arrays; + the factory's write_kv_data_to_file hook then runs, and must push the bulk + arrays past those split.* scalars too. This is why the hook is on + write_kv_data_to_file, not write_header_to_file.""" + w = GGUFWriter(tempfile.mktemp(suffix=".gguf"), "demo") + w.add_string("general.name", "x") + w.add_array("tokenizer.ggml.tokens", ["a", "b"]) # bulk, emitted first + w.add_uint16("split.no", 0) # appended after bulk + w.add_uint16("split.count", 2) # (simulates add_shard_kv_data) + move_bulk_metadata_last(w) + keys = list(w.kv_data[0].keys()) + assert keys.index("split.no") < keys.index("tokenizer.ggml.tokens"), keys + assert keys.index("split.count") < keys.index("tokenizer.ggml.tokens"), keys + + +def test_factory_writes_trailer_on_disk(): + """End-to-end: build via gguf_writer(), write a real file, read it back, and + confirm every scalar KV precedes the bulk trailer in the actual bytes.""" + out = Path(tempfile.mkdtemp()) / "t.gguf" + w = gguf_writer(str(out), "demo") + _emit_mixed(w) + w.add_tensor("enc.weight", np.zeros((2, 2), dtype=np.float32)) + w.write_header_to_file() + w.write_kv_data_to_file() + w.write_tensors_to_file() + w.close() + + fields = [k for k in GGUFReader(str(out)).fields if not k.startswith("GGUF.")] + bulk = {"tokenizer.ggml.tokens", "tokenizer.ggml.merges"} + present = [k for k in fields if k in bulk] + assert present, fields + first_bulk = min(fields.index(k) for k in present) + last_scalar = max(fields.index(k) for k in fields if k not in bulk) + assert first_bulk > last_scalar, fields + assert fields.index("stt.demo.encoder.n_layers") < first_bulk, fields + + +def test_bulk_keys_are_tokenizer_only(): + """Guard: the bulk set must not accidentally include scalar identity KVs.""" + assert "tokenizer.ggml.model" not in BULK_KV_KEYS + assert "tokenizer.ggml.tokens" in BULK_KV_KEYS + + +def _main() -> int: + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + failed = 0 + for t in tests: + try: + t() + print(f"PASS {t.__name__}") + except AssertionError as e: + failed += 1 + print(f"FAIL {t.__name__}: {e}") + print(f"\n{len(tests) - failed}/{len(tests)} passed") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/scripts/lib/test_quant_policy_sync.py b/scripts/lib/test_quant_policy_sync.py new file mode 100644 index 00000000..c0935ca9 --- /dev/null +++ b/scripts/lib/test_quant_policy_sync.py @@ -0,0 +1,229 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "gguf>=0.10", +# ] +# /// +"""Pin the converter-side quant bucketing against the canonical policy. + +There are three hand-maintained copies of "what bucket does tensor X belong +to" in this repo: + + 1. tools/transcribe-quantize/policy.cpp::classify_tensor — CANONICAL. + The C++ quantizer's per-tensor bucket table (Norm / Conv / ConvPw / + Linear / Embed). It is the source of truth and mirrors the loader's + dtype allowlist (src/transcribe-weights-util.h). + 2. scripts/lib/gguf_common.py::reference_dtype_for — this test. + The Python helper every converter uses to pick a per-tensor dtype when + emitting a reference-tier (F32/F16/BF16) GGUF. It re-implements a SUBSET + of (1): it only needs the Norm (-> F32) and Conv (-> F16 when the + reference dtype is BF16, which the loader has no conv kernel for) rules, + because at the reference dtype every other tensor just keeps that dtype. + 3. scripts/convert-funasr_nano.py::per_tensor_target_dtype — a family-local + fork of (2). Not exercised here (importing it pulls torch); documented in + that file. If it ever folds back into the shared helper, delete it. + +Copies (1) and (2) carry "keep in sync" comments but nothing enforced them. +This module is that enforcement, for the Python copy: it locks the dtype +`reference_dtype_for` assigns to a representative tensor from every bucket and +every family-specific override, so the helper cannot silently regress or drift +further from policy.cpp. The name lists below are a transcription of +policy.cpp::classify_tensor — keep them in sync with that file (now there is a +single, *tested* transcription instead of a silent second implementation). + +It is intentionally NOT a cross-process check against the C++ binary: that +would need a built transcribe-quantize and the multi-GB reference GGUFs, which +do not belong in a fast unit test. The canonical buckets are transcribed here +as data and reviewed against policy.cpp. + +Run standalone (exit-code driven): uv run scripts/lib/test_quant_policy_sync.py +Or under pytest: pytest scripts/lib/test_quant_policy_sync.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from gguf import GGMLQuantizationType as T + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # repo scripts/ +from lib.gguf_common import reference_dtype_for # noqa: E402 + + +# --------------------------------------------------------------------------- +# Contract corpus — transcribed from policy.cpp::classify_tensor. +# +# Each list groups tensor names by the bucket reference_dtype_for IMPLEMENTS +# today. The asserts below check the helper actually routes them that way at +# both reference dtypes the converters use. +# --------------------------------------------------------------------------- + +# Norm bucket: biases, LayerNorm/RMSNorm scales, positional tables, frontend +# buffers. Loader requires F32 in these slots -> F32 at every reference dtype. +NORM = [ + "dec.layers.0.attn.linear_q.bias", # .bias + "enc.blocks.3.norm_ff1.weight", # norm_ prefix + "enc.blocks.3.conv.bn.weight", # .bn. batchnorm + "dec.final_norm.weight", # cohere final norm (dot separator) + "dec.embed.norm.weight", # cohere embed norm (dot separator) + "dec.layers.0.attn.q_norm.weight", # qwen3 per-head q_norm + "dec.layers.0.attn.k_norm.weight", # qwen3 per-head k_norm + "dec.output_norm.weight", # qwen3 final RMSNorm + "enc.layers.0.ln_post.weight", # qwen3 encoder ln_post + "enc.layers.0.ln_pre.weight", # qwen3 encoder ln_pre + "enc.blocks.0.self_attn.pos_bias_u", # conformer rel-pos bias u + "enc.blocks.0.self_attn.pos_bias_v", # conformer rel-pos bias v + "dec.pos_enc", # cohere sinusoidal pos table + "enc.pos_emb.weight", # whisper encoder pos_emb + "dec.pos_emb.weight", # whisper decoder pos_emb + "frontend.mel_filterbank", # mel frontend buffer + "frontend.window", # window frontend buffer +] + +# Conv bucket: 2D / depthwise / 1x1 pointwise conv kernels. The loader has no +# BF16 conv kernel, so at BF16 reference these downcast to F16; at F32/F16 +# reference they keep the reference dtype. +CONV = [ + "enc.blocks.3.conv.pointwise1.weight", # conformer 1x1 pointwise + "enc.blocks.3.conv.pointwise2.weight", # conformer 1x1 pointwise + "enc.pre_encode.conv.0.weight", # pre-encode subsampling conv + "enc.blocks.3.conv.depthwise.weight", # conformer depthwise conv +] + +# Linear / Embed: ggml_mul_mat operands and the decoder token embedding. +# Keep the reference dtype unchanged (block quantization is a Stage-5 concern). +LINEAR = [ + "enc.blocks.3.attn.linear_q.weight", # attention projection + "dec.layers.0.ffn.up.weight", # FFN matrix + "enc.blocks.3.attn.linear_out.weight", # attention output projection + "dec.embed.token.weight", # cohere tied embedding (Embed) + "dec.token_embd.weight", # llama-style embedding (Embed) +] + +# KNOWN DRIFT — policy.cpp::classify_tensor places these in the Norm (F32) or +# Conv (F16) bucket, but reference_dtype_for does NOT implement the matching +# rule, so it currently returns the reference dtype unchanged. +# +# This is SAFE today only because every family that emits one of these names +# either: +# (a) converts at F32 reference, where reference_dtype_for is a no-op +# (sensevoice: cmvn/after_norm/tp_norm/enc.embed; canary: norm{1,2,3}/ +# dec.norm; moonshine-streaming as shipped: enc.embedder.comp.log_k), +# (b) special-cases the tensor to F32 in its own converter, bypassing this +# helper (voxtral-realtime: dec.time_embed.inv_freq, emitted F32), or +# (c) uses a family-local converter that does not call this helper at all +# (granite_nar: conv_bn.*, prj.*, conv_pointwise/conv_depthwise). +# +# Pinned here so the gap is visible and locked. If a BF16/F16-reference family +# ever emits one of these names through reference_dtype_for, the helper will +# silently mis-store the tensor (e.g. a norm at BF16 where the loader wants +# F32). The fix is to add the rule to reference_dtype_for and MOVE the name +# into NORM / CONV above — this test will fail (the name no longer returns the +# reference dtype) until you do, which is the intended tripwire. +# +# Comment on each = the canonical policy.cpp bucket it *should* map to. +KNOWN_DRIFT = [ + "enc.blocks.3.conv_bn.weight", # Norm (granite_nar conv_bn) + "enc.blocks.3.conv_bn.running_mean", # Norm (granite_nar BN running stat) + "enc.blocks.3.conv_bn.running_var", # Norm (granite_nar BN running stat) + "prj.out_norm.weight", # Norm (granite_nar projector LN) + "prj.layer_norms.2.weight", # Norm (granite_nar per-layer LN) + "dec.layer.5.norm1.weight", # Norm (canary decoder LN) + "dec.layer.5.norm2.weight", # Norm (canary decoder LN) + "dec.layer.5.norm3.weight", # Norm (canary decoder LN) + "dec.norm.weight", # Norm (canary final decoder LN) + "prj.query", # Norm (granite_nar projector query) + "prj.window_positions", # Norm (granite_nar window bias) + "frontend.cmvn.shift", # Norm (sensevoice CMVN) + "frontend.cmvn.scale", # Norm (sensevoice CMVN) + "enc.embed.weight", # Norm (sensevoice prefix-token table) + "enc.after_norm.weight", # Norm (sensevoice trailing LN) + "tp_encoders.tp_norm.weight", # Norm (sensevoice tp LN) + "enc.embedder.comp.log_k", # Norm (moonshine-streaming asinh scalar) + "dec.time_embed.inv_freq", # Norm (voxtral-realtime time-embed table) + "enc.blocks.3.conv_pointwise1.weight", # ConvPw (granite_nar underscore form) + "enc.blocks.3.conv_depthwise.weight", # Conv (granite_nar underscore form) + "enc.blocks.3.attn.fsmn.weight", # Conv (sensevoice FSMN depthwise) +] + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_f32_reference_is_all_f32() -> None: + """The safety net the F32-reference families rely on: at F32 reference, + every tensor — whatever its bucket — is stored F32. This is what makes the + KNOWN_DRIFT names safe for sensevoice / canary / moonshine-streaming.""" + for name in NORM + CONV + LINEAR + KNOWN_DRIFT: + got = reference_dtype_for(name, T.F32) + assert got == T.F32, f"{name}: F32 reference must stay F32, got {got.name}" + + +def test_norm_bucket_is_f32_at_bf16() -> None: + """Implemented Norm rules: F32 regardless of reference dtype.""" + for name in NORM: + got = reference_dtype_for(name, T.BF16) + assert got == T.F32, f"{name}: Norm bucket must be F32, got {got.name}" + + +def test_conv_bucket_downcasts_to_f16_at_bf16() -> None: + """Implemented Conv rules: BF16 reference -> F16 (no BF16 conv kernel); + F16 reference keeps F16.""" + for name in CONV: + got_bf16 = reference_dtype_for(name, T.BF16) + assert got_bf16 == T.F16, f"{name}: Conv at BF16 ref must be F16, got {got_bf16.name}" + got_f16 = reference_dtype_for(name, T.F16) + assert got_f16 == T.F16, f"{name}: Conv at F16 ref must be F16, got {got_f16.name}" + + +def test_linear_keeps_reference_dtype() -> None: + """Linear / Embed operands keep the reference dtype (no quant at convert).""" + for name in LINEAR: + assert reference_dtype_for(name, T.BF16) == T.BF16, f"{name}: must keep BF16" + assert reference_dtype_for(name, T.F16) == T.F16, f"{name}: must keep F16" + + +def test_known_drift_is_pinned() -> None: + """Lock the documented gaps: these C++ Norm/Conv tensors currently fall + through reference_dtype_for to the reference dtype. If you implement the + missing rule, this assert flips — move the name into NORM / CONV.""" + for name in KNOWN_DRIFT: + got = reference_dtype_for(name, T.BF16) + assert got == T.BF16, ( + f"{name}: KNOWN_DRIFT no longer returns the reference dtype " + f"(got {got.name}) — reference_dtype_for grew a rule for it; " + f"move it from KNOWN_DRIFT into NORM or CONV." + ) + + +_TESTS = [ + test_f32_reference_is_all_f32, + test_norm_bucket_is_f32_at_bf16, + test_conv_bucket_downcasts_to_f16_at_bf16, + test_linear_keeps_reference_dtype, + test_known_drift_is_pinned, +] + + +def main() -> int: + failures = 0 + for t in _TESTS: + try: + t() + except AssertionError as e: + failures += 1 + print(f"FAIL {t.__name__}: {e}") + else: + print(f"ok {t.__name__}") + n_names = len(NORM) + len(CONV) + len(LINEAR) + len(KNOWN_DRIFT) + print(f"\n{len(_TESTS) - failures}/{len(_TESTS)} checks passed " + f"over {n_names} tensor names " + f"({len(KNOWN_DRIFT)} pinned as known drift).") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/preflight.py b/scripts/preflight.py index 8bb3b562..1b90cc42 100755 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -697,54 +697,143 @@ def check_architecture_sanity(declared, gguf_kvs, reference, gate: Gate) -> Chec return CheckResult("architecture_sanity", gate, "pass", sources) +# Boolean capability flags, declared (intake) field -> GGUF KV. Compared only +# when BOTH sides are present: a missing GGUF capability KV means the C++ loader +# derives the flag from family defaults (transcribe-meta.cpp apply_family_ +# invariants), which is not a mismatch. Adding a new boolean capability is one +# row here once it has both an intake field and a GGUF KV. +_CAP_BOOL_FLAGS = ( + ("language_detection", "stt.capability.lang_detect"), + ("translation", "stt.capability.translate"), + ("streaming", "stt.capability.streaming"), + ("speaker_diarization", "stt.capability.speaker_diarization"), +) + + +def _cmp_bool(decl, gguf) -> bool: + """True when both sides are present and disagree.""" + return decl is not None and gguf is not None and bool(decl) != bool(gguf) + + +def _cmp_list_set(decl, gguf) -> tuple[str | None, str | None]: + """Compare two lists as sets. Returns (mismatch_detail, warn_detail), each + None when not applicable. Set-difference is a mismatch; identical contents + in a different order is a warning. Skips when declared is empty or the GGUF + side is absent.""" + if not decl or gguf is None: + return None, None + if set(decl) != set(gguf): + only_decl = sorted(set(decl) - set(gguf)) + only_gguf = sorted(set(gguf) - set(decl)) + diff = [] + if only_decl: + diff.append(f"only in declared: {only_decl}") + if only_gguf: + diff.append(f"only in gguf: {only_gguf}") + return "; ".join(diff), None + if list(decl) != list(gguf): + return None, "order differs (contents match)" + return None, None + + def check_capabilities(declared, gguf_kvs, reference, gate: Gate) -> CheckResult: - """Cross-check declared languages + capability flags against the GGUF. + """Cross-check declared capabilities against the GGUF. - Only runs at Gate B (GGUF exists). Gate A returns warn. + Only runs meaningfully at Gate B (GGUF exists); Gate A returns warn. + Every comparison is presence-gated: a field is checked only when the intake + declares it AND the GGUF carries the KV, because a missing GGUF capability + KV is a valid "derive from family default" signal, not a contradiction. """ caps = declared.get("capabilities") or {} - declared_langs = list(caps.get("languages") or []) - declared_lang_detect = caps.get("language_detection") if not gguf_kvs: return CheckResult("capabilities", gate, "warn", - {"declared_languages": declared_langs, - "declared_language_detection": declared_lang_detect}, + {"declared": caps}, "skipped at gate A; GGUF does not exist yet") - gguf_langs_raw = gguf_kvs.get("general.languages") - gguf_langs = gguf_langs_raw if isinstance(gguf_langs_raw, list) else None - gguf_lang_detect = gguf_kvs.get("stt.capability.lang_detect") - - sources: dict[str, Any] = { - "declared_languages": declared_langs, - "gguf_languages": gguf_langs, - "declared_language_detection": declared_lang_detect, - "gguf_lang_detect": gguf_lang_detect, - } - + sources: dict[str, Any] = {} mismatches: list[str] = [] warnings: list[str] = [] - if declared_langs and gguf_langs is not None: - if set(declared_langs) != set(gguf_langs): - only_decl = sorted(set(declared_langs) - set(gguf_langs)) - only_gguf = sorted(set(gguf_langs) - set(declared_langs)) - diff = [] - if only_decl: - diff.append(f"only in declared: {only_decl}") - if only_gguf: - diff.append(f"only in gguf: {only_gguf}") - mismatches.append("languages differ — " + "; ".join(diff)) - elif declared_langs != gguf_langs: - warnings.append("language order differs (contents match)") - - if declared_lang_detect is not None and gguf_lang_detect is not None: - if bool(declared_lang_detect) != bool(gguf_lang_detect): + # -- languages (set) -- + decl_langs = list(caps.get("languages") or []) + gguf_langs_raw = gguf_kvs.get("general.languages") + gguf_langs = gguf_langs_raw if isinstance(gguf_langs_raw, list) else None + sources["declared_languages"] = decl_langs + sources["gguf_languages"] = gguf_langs + m, w = _cmp_list_set(decl_langs, gguf_langs) + if m: + mismatches.append("languages differ - " + m) + if w: + warnings.append("language " + w) + + # -- boolean capability flags (declarative) -- + for field, key in _CAP_BOOL_FLAGS: + decl = caps.get(field) + gguf = gguf_kvs.get(key) + sources[f"declared_{field}"] = decl + sources[f"gguf_{key.rsplit('.', 1)[-1]}"] = gguf + if _cmp_bool(decl, gguf): + mismatches.append(f"{field} declared={decl} but gguf {key}={gguf}") + + # -- translation target languages + pairs (conditional on translation) -- + decl_translation = caps.get("translation") + gguf_translate = gguf_kvs.get("stt.capability.translate") + decl_targets = list(caps.get("translation_target_languages") or []) + gguf_targets_raw = gguf_kvs.get("stt.translation.target_languages") + gguf_targets = gguf_targets_raw if isinstance(gguf_targets_raw, list) else None + decl_pairs = list(caps.get("translation_pairs") or []) + gguf_pairs_raw = gguf_kvs.get("stt.translation.pairs") + gguf_pairs = gguf_pairs_raw if isinstance(gguf_pairs_raw, list) else None + sources["declared_translation_target_languages"] = decl_targets + sources["gguf_translation_target_languages"] = gguf_targets + sources["declared_translation_pairs"] = decl_pairs + sources["gguf_translation_pairs"] = gguf_pairs + + if decl_translation is True and gguf_translate is not None and bool(gguf_translate): + if gguf_targets is None: + mismatches.append("translation target languages missing " + "(expected stt.translation.target_languages)") + else: + m, w = _cmp_list_set(decl_targets, gguf_targets) + if m: + mismatches.append("translation target languages differ - " + m) + if w: + warnings.append("translation target language " + w) + + if decl_pairs: + if gguf_pairs is None: + mismatches.append("translation pairs missing " + "(expected stt.translation.pairs)") + else: + m, w = _cmp_list_set(decl_pairs, gguf_pairs) + if m: + mismatches.append("translation pairs differ - " + m) + if w: + warnings.append("translation pair " + w) + + # -- timestamps: intake declares granularity as an enum array + # (none/segment/word/token); the GGUF carries two booleans. Map the + # array down to those booleans. Empty list = unset default -> skip, + # mirroring None for the boolean flags. -- + ts_val = caps.get("timestamps") + decl_ts = set(ts_val) if isinstance(ts_val, list) else set() + gguf_ts = gguf_kvs.get("stt.capability.timestamps") + gguf_word_ts = gguf_kvs.get("stt.capability.word_timestamps") + sources["declared_timestamps"] = sorted(decl_ts) + sources["gguf_timestamps"] = gguf_ts + sources["gguf_word_timestamps"] = gguf_word_ts + if decl_ts: + decl_any_ts = bool(decl_ts - {"none"}) + decl_word_ts = "word" in decl_ts + if _cmp_bool(decl_any_ts, gguf_ts): mismatches.append( - f"language_detection declared={declared_lang_detect} " - f"but gguf stt.capability.lang_detect={gguf_lang_detect}" - ) + f"timestamps declared={sorted(decl_ts)} (any={decl_any_ts}) " + f"but gguf stt.capability.timestamps={gguf_ts}") + if _cmp_bool(decl_word_ts, gguf_word_ts): + mismatches.append( + f"timestamps declared={sorted(decl_ts)} (word={decl_word_ts}) " + f"but gguf stt.capability.word_timestamps={gguf_word_ts}") if mismatches: return CheckResult("capabilities", gate, "fail", sources, "; ".join(mismatches)) diff --git a/scripts/wer/remote/modal_sweep.py b/scripts/wer/remote/modal_sweep.py index c1ab722f..ec85ffac 100644 --- a/scripts/wer/remote/modal_sweep.py +++ b/scripts/wer/remote/modal_sweep.py @@ -131,6 +131,8 @@ def _build_dir(gpu_id: str) -> str: "samples/wer/raw/**", "samples/wer/librispeech-*/**", "samples/wer/fleurs-*/**", "samples/wer/*.manifest.jsonl", "reports/**", + "target/**", "**/target/**", "bindings/**", "samples/**", + "dist/**", "wheelhouse-local/**", "notes/**", "canary/**", "scripts/envs/**", "**/__pycache__/**", "**/.venv/**", "**/.uv/**", "**/.git/**", ".git/**", ".claude/**", "docs/**", "tests/golden/**", @@ -164,6 +166,8 @@ def _build_dir(gpu_id: str) -> str: "samples/wer/raw/**", "samples/wer/librispeech-*/**", "samples/wer/fleurs-*/**", "samples/wer/*.manifest.jsonl", "reports/**", + "target/**", "**/target/**", "bindings/**", "samples/**", + "dist/**", "wheelhouse-local/**", "notes/**", "canary/**", "scripts/envs/**/.venv/**", "**/__pycache__/**", "**/.uv/**", "**/.git/**", ".git/**", ".claude/**", "docs/**", "tests/golden/**", @@ -285,7 +289,18 @@ def debug_dump(model_repo: str, model_file: str) -> dict: token=os.environ["HF_TOKEN"], ) audio = "/work/samples/jfk.wav" - assert os.path.exists(audio), f"missing sample: {audio}" + if not os.path.exists(audio): + # samples/** is excluded from the source mount (it holds the multi-GB + # LibriSpeech extract), so synthesize a short 16 kHz tone — enough to + # exercise model load + first compute, which is where load-time CUDA + # crashes surface. + import wave, struct, math + audio = "/tmp/_debug_tone.wav" + with wave.open(audio, "w") as w: + w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000) + w.writeframes(b"".join( + struct.pack(" None: Prints stderr lines that contain STATS so we can diff F16 vs Q4_K_M.""" result = debug_dump.remote(model_repo, model_file) print(f"=== {result['model_repo']} / {result['model_file']} (rc={result['rc']}) ===") + if result["rc"] != 0: + # Crash case (e.g. CUDA load/compute abort): the filtered STATS view + # would hide the actual error, so dump full stderr + stdout verbatim. + print("--- FULL STDERR (rc != 0) ---") + print(result["stderr"]) + print("--- FULL STDOUT (rc != 0) ---") + print(result["stdout"]) + return for line in result["stderr"].splitlines(): if "STATS " in line or "FAIL" in line.upper() or "ERR" in line.upper(): print(line) diff --git a/scripts/wer/run.py b/scripts/wer/run.py index 3f1b5c23..f4ea37af 100644 --- a/scripts/wer/run.py +++ b/scripts/wer/run.py @@ -60,6 +60,7 @@ import argparse import json +import os import subprocess import sys import tempfile @@ -78,6 +79,25 @@ ) +def read_stderr_tail(path: str, max_lines: int = 50, max_bytes: int = 65536) -> str: + """Return the last ``max_lines`` lines of a captured stderr file. + + Bounded read (only the trailing ``max_bytes``) so a chatty run can't blow + up memory. Used to surface a native crash (e.g. a CUDA ``GGML_ASSERT``) on + a non-zero CLI exit — see the capture in ``run_one``. + """ + try: + with open(path, "rb") as f: + f.seek(0, os.SEEK_END) + size = f.tell() + f.seek(max(0, size - max_bytes)) + data = f.read() + except OSError: + return "" + lines = data.decode("utf-8", errors="replace").splitlines() + return "\n".join(lines[-max_lines:]).strip() + + def find_repo_root(start: Path) -> Path: p = start.resolve() while p != p.parent: @@ -332,15 +352,25 @@ def _dur(e: dict) -> float: print(f" $ {' '.join(cmd[:6])} ...") t_start = time.monotonic() + # Capture the CLI's stderr to a temp file (always on) so a non-zero exit — + # a native crash like a CUDA GGML_ASSERT, an OOM, or a truncation WARN — is + # diagnosable straight from this run's log instead of being discarded. + # Surfaced only on failure (see below), so successful runs stay quiet. A + # file (not a PIPE) can't deadlock against the stdout read loop below. + stderr_cap = tempfile.NamedTemporaryFile( + mode="w+b", suffix=".stderr", delete=False + ) try: proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, + stderr=stderr_cap, text=True, errors="replace", ) except Exception as e: + stderr_cap.close() + Path(stderr_cap.name).unlink(missing_ok=True) print(f"error: failed to start transcribe-cli: {e}", file=sys.stderr) return 1 @@ -422,6 +452,7 @@ def _dur(e: dict) -> float: f"errors={n_errors}") proc.wait() + stderr_cap.close() wall = time.monotonic() - t_start # Clean up temp file. @@ -440,6 +471,18 @@ def _dur(e: dict) -> float: f"(encode {100*sum_encode/stage_total:.0f}% / " f"decode {100*sum_decode/stage_total:.0f}%)") print(f"report: {out_path}") + + # Always-on, failure-only diagnostics: on a non-zero CLI exit, surface a + # bounded tail of its stderr so the failure is diagnosable from this log + # alone (and so modal_sweep's "stderr tail" carries the real error — e.g. a + # CUDA GGML_ASSERT — instead of an empty string). Quiet on success. + if proc.returncode != 0: + tail = read_stderr_tail(stderr_cap.name) + if tail: + print(f"--- transcribe-cli stderr (tail, exit {proc.returncode}) ---", + file=sys.stderr) + print(tail, file=sys.stderr) + Path(stderr_cap.name).unlink(missing_ok=True) return 0 if proc.returncode == 0 else 1 diff --git a/src/arch/canary/capabilities.cpp b/src/arch/canary/capabilities.cpp index 02706710..1f5a6fa3 100644 --- a/src/arch/canary/capabilities.cpp +++ b/src/arch/canary/capabilities.cpp @@ -9,11 +9,10 @@ void apply_family_invariants(transcribe_model & model) { caps.native_sample_rate = 16000; - // Canary is a multitask AED — every variant supports en->de/es/fr - // translation plus en/de/es/fr ASR, except canary-1b which is - // English-ASR-only. The runtime advertises translate=true at the - // family default; the GGUF's stt.capability.translate value (read - // by read_capability_kv after this call) overrides per-variant. + // Canary is a multitask AED. The runtime advertises translate=true at the + // family default; the GGUF's stt.capability.translate value (read by + // read_capability_kv after this call) overrides per-variant. Exact + // directions are an optional GGUF contract in stt.translation.pairs. caps.supports_translate = true; // V1 port: timestamps are explicitly experimental upstream and diff --git a/src/arch/canary/model.cpp b/src/arch/canary/model.cpp index 5634e7b6..a08750eb 100644 --- a/src/arch/canary/model.cpp +++ b/src/arch/canary/model.cpp @@ -752,6 +752,24 @@ int find_language_id(const CanaryHParams & hp, const char * lang) { return -1; } +bool translation_pair_allowed(const CanaryHParams & hp, + const char * src, + const char * dst) { + if (hp.translation_pairs.empty()) { + return true; + } + if (src == nullptr || dst == nullptr || src[0] == '\0' || dst[0] == '\0') { + return false; + } + const std::string pair = std::string(src) + ">" + dst; + for (const auto & allowed : hp.translation_pairs) { + if (allowed == pair) { + return true; + } + } + return false; +} + transcribe_status run( transcribe_session * session, const float * pcm, @@ -980,6 +998,13 @@ transcribe_status run( tgt_lang, cm->hparams.languages.size()); return TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE; } + if (is_translate && + !translation_pair_allowed(cm->hparams, lang, tgt_lang)) { + log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, + "canary run: translation pair '%s>%s' is not advertised", + lang, tgt_lang); + return TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE; + } // Generic transcribe_run_params::pnc routes here. DEFAULT maps to the // model's shipped behavior (pnc=on; matches the upstream model card's @@ -1629,6 +1654,9 @@ transcribe_status run_batch( const int src_id = find_language_id(hp, lang); const int tgt_id = find_language_id(hp, tgt_lang); if (src_id < 0 || tgt_id < 0) return TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE; + if (is_translate && !translation_pair_allowed(hp, lang, tgt_lang)) { + return TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE; + } bool pnc = true; if (params != nullptr && params->pnc == TRANSCRIBE_PNC_MODE_OFF) pnc = false; std::vector prompt_ids; diff --git a/src/arch/canary/weights.cpp b/src/arch/canary/weights.cpp index 530abcae..7f044730 100644 --- a/src/arch/canary/weights.cpp +++ b/src/arch/canary/weights.cpp @@ -160,6 +160,21 @@ transcribe_status read_canary_hparams(const gguf_context * gguf, } } + { + std::vector pairs; + switch (read_string_array_kv(gguf, "stt.translation.pairs", pairs)) { + case KvResult::Absent: + break; + case KvResult::Ok: + hp.translation_pairs = pairs; + break; + case KvResult::BadType: + log_msg(TRANSCRIBE_LOG_LEVEL_ERROR, + "%s: stt.translation.pairs wrong type", kFamilyTag); + return TRANSCRIBE_ERR_GGUF; + } + } + // Frontend. if (auto st = read_required_string_kv(gguf, "stt.frontend.type", kFamilyTag, hp.fe_type); st != TRANSCRIBE_OK) return st; if (auto st = read_required_u32_kv(gguf, "stt.frontend.num_mels", kFamilyTag, hp.fe_num_mels); st != TRANSCRIBE_OK) return st; diff --git a/src/arch/canary/weights.h b/src/arch/canary/weights.h index 76ded255..c74c782b 100644 --- a/src/arch/canary/weights.h +++ b/src/arch/canary/weights.h @@ -92,6 +92,10 @@ struct CanaryHParams { std::vector languages; // e.g. {"en","de","es","fr"} std::vector language_ids; // parallel: id of "<|en|>" etc. + // Optional GGUF contract: allowed translation pairs as "src>target". + // Old GGUFs do not carry this KV; an empty list preserves legacy behavior. + std::vector translation_pairs; + // Frontend (mel feature extractor). std::string fe_type; int32_t fe_num_mels = 0; diff --git a/src/arch/granite/capabilities.cpp b/src/arch/granite/capabilities.cpp index 49a5466e..ae040c40 100644 --- a/src/arch/granite/capabilities.cpp +++ b/src/arch/granite/capabilities.cpp @@ -23,7 +23,7 @@ void apply_family_invariants(transcribe_model & model) { // chat-template prompt. The -plus variant drops the translation // capability (its model card lists ASR + speaker diarization, not // translation). The default here is true so the loader can lower - // it per-variant from the GGUF KV stt.capability.translation. + // it per-variant from the GGUF KV stt.capability.translate. caps.supports_translate = true; // Cancellation is wired at the per-run level. Whisper-specific diff --git a/src/arch/granite/model.cpp b/src/arch/granite/model.cpp index 4194ded0..d786b612 100644 --- a/src/arch/granite/model.cpp +++ b/src/arch/granite/model.cpp @@ -531,7 +531,8 @@ transcribe_status init_context( // replaces this body with the full pipeline. // Map a BCP-47 language code or English name to the language name the // granite-speech instruction expects. Returns nullptr for unsupported. -// granite-speech (1b/2b/-plus) advertises fr / de / es / pt / ja. +// granite-speech base AR variants advertise fr/de/es/pt/ja plus translate-only +// targets it/zh; -plus is ASR-only, so this helper is not used for it. static const char * granite_target_language_name(const char * code_or_name) { if (code_or_name == nullptr || *code_or_name == '\0') return nullptr; std::string s = code_or_name; @@ -541,6 +542,9 @@ static const char * granite_target_language_name(const char * code_or_name) { if (s == "es" || s == "spa" || s == "spanish") return "Spanish"; if (s == "pt" || s == "por" || s == "portuguese") return "Portuguese"; if (s == "ja" || s == "jpn" || s == "japanese") return "Japanese"; + if (s == "it" || s == "ita" || s == "italian") return "Italian"; + if (s == "zh" || s == "zh-cn" || s == "cmn" || + s == "mandarin" || s == "chinese") return "Mandarin"; if (s == "en" || s == "eng" || s == "english") return "English"; return nullptr; } diff --git a/src/arch/moonshine_streaming/model.cpp b/src/arch/moonshine_streaming/model.cpp index b1698243..c91531cf 100644 --- a/src/arch/moonshine_streaming/model.cpp +++ b/src/arch/moonshine_streaming/model.cpp @@ -460,6 +460,33 @@ int samples_per_encoder_frame(const MoonshineStreamingHParams & hp) { return 4 * hp.enc_frame_len; } +// Greedy-decode generation budget, in tokens. Some inputs never trigger EOS +// — a repeated-digit "double nine ... double nine" phone-number clip makes +// the model emit one token forever, identical to the HF reference — so the +// greedy loop needs a bound tighter than the architectural position cap +// (dec_max_position_embeddings, thousands of tokens) to avoid a long, +// pointless compute loop. The upstream model card recommends +// max_new_tokens ~= audio_seconds * 6.5; we follow that, plus a small floor +// so very short clips keep ample headroom, and let the caller take min() +// with the position cap (so this only ever tightens, never loosens, the +// existing wall). Audio length is derived from T_enc: one encoder frame +// spans samples_per_encoder_frame(hp) PCM samples at the 16 kHz native rate. +// Returns 0 when the frame geometry is unknown, meaning "no duration bound". +int decode_generation_budget(const MoonshineStreamingHParams & hp, int T_enc) { + if (hp.enc_frame_len <= 0 || T_enc <= 0) { + return 0; + } + constexpr int64_t k_native_sr_hz = 16000; + constexpr int64_t k_budget_num = 13; // 6.5 tokens/sec, numerator + constexpr int64_t k_budget_den = 2; // denominator + constexpr int64_t k_budget_floor = 24; // headroom for very short clips + const int64_t audio_samples = + static_cast(T_enc) * samples_per_encoder_frame(hp); + return static_cast( + audio_samples * k_budget_num / (k_budget_den * k_native_sr_hz) + + k_budget_floor); +} + // Encoder helper: build the encoder graph for `n_samples` PCM, // upload PCM + per-layer sliding-window masks, compute, and read the // final-LN output into the caller-provided host vector. Updates @@ -885,6 +912,15 @@ transcribe_status decode_from_kv_cache( const int eos = hp.eos_token_id; // 2 const int max_pos = hp.dec_max_position_embeddings; + // Effective stop bound for the greedy loop: the duration-based generation + // budget AND the hard decoder position cap, whichever is tighter. The + // duration budget bounds runaway loops (inputs the model never ends) to a + // few dozen tokens; the position cap remains the absolute backstop. A + // budget of 0 (unknown frame geometry) falls back to the position cap. + const int dur_budget = decode_generation_budget(hp, T_enc); + const int gen_cap = (dur_budget > 0 && (max_pos <= 0 || dur_budget < max_pos)) + ? dur_budget : max_pos; + std::vector generated_ids; int next_token = -1; int n_past = 0; @@ -1011,7 +1047,7 @@ transcribe_status decode_from_kv_cache( // dec.logits_raw.gen20 dumps the logits that predict the 20th // emitted token (n_past == 20 at that step). Matches moonshine. constexpr int k_mid_gen_step = 20; - while (next_token != eos && n_past < max_pos) { + while (next_token != eos && n_past < gen_cap) { if (cc->poll_abort()) return TRANSCRIBE_ERR_ABORTED; const bool is_mid_gen = (n_past == k_mid_gen_step); @@ -1031,20 +1067,24 @@ transcribe_status decode_from_kv_cache( } // The greedy loop exits either because next_token == eos (complete) or - // because n_past reached the position cap (max_pos). When the last token - // was not eos the transcript hit the output cap before end-of-stream and + // because n_past reached gen_cap — the tighter of the duration-based + // generation budget and the hard position cap (max_pos). When the last + // token was not eos the transcript hit the cap before end-of-stream and // is incomplete: flag it via transcribe_was_truncated() and emit one WARN, - // mirroring qwen3_asr. The loop itself is unchanged — this only observes - // its exit. See docs/input-limits.md. + // mirroring qwen3_asr. Reaching the duration budget (gen_cap < max_pos) + // typically means a hallucination loop the model never ends — bounding it + // here avoids a long, pointless decode. See docs/input-limits.md. if (next_token != eos) { cc->was_truncated = true; + const bool hit_duration_budget = (gen_cap < max_pos) || (max_pos <= 0); transcribe::log_msg( TRANSCRIBE_LOG_LEVEL_WARN, "moonshine run: output truncated at %d tokens — decode reached the " - "position cap (%d) before end-of-stream; the transcript may be " - "incomplete. This model is intended for short utterances. See " - "transcribe_capabilities.max_audio_ms.", - static_cast(generated_ids.size()), max_pos); + "%s (%d) before end-of-stream; the transcript may be incomplete. " + "See transcribe_capabilities.max_audio_ms.", + static_cast(generated_ids.size()), + hit_duration_budget ? "generation budget" : "position cap", + gen_cap); } cc->t_decode_us += ggml_time_us() - t_decode_start; diff --git a/src/conformer/conformer.cpp b/src/conformer/conformer.cpp index 94ae11c4..4d007664 100644 --- a/src/conformer/conformer.cpp +++ b/src/conformer/conformer.cpp @@ -255,6 +255,22 @@ ggml_tensor * conv_2d_dw_f32(ggml_context * ctx, return result; } +// ggml_conv_2d_dw_direct requires an F32 kernel on CUDA (a hard GGML_ASSERT +// in conv2d-dw.cu) and silently misbehaves for F16 kernels on other backends +// — see conv_1d_dw_f32's batch path below and the canary_qwen note. A +// BF16-reference conformer (e.g. cohere) ships its depthwise kernels at F16, +// so promote to F32 in-graph before any direct depthwise op. The depthwise +// kernel is tiny, so the cast is negligible; F32 kernels pass through +// untouched. Only the direct_dw path (CUDA/Vulkan) routes here — the im2col +// fallback used on Metal/CPU takes the kernel's real type and is unaffected. +static ggml_tensor * dw_kernel_for_direct(ggml_context * ctx, + ggml_tensor * kernel) { + if (kernel == nullptr || kernel->type == GGML_TYPE_F32) { + return kernel; + } + return ggml_cast(ctx, kernel, GGML_TYPE_F32); +} + // f32-friendly depthwise Conv1D (mirrors ggml_conv_1d_dw but // passes the kernel's real type to im2col). Same fix as above. ggml_tensor * conv_1d_dw_f32(ggml_context * ctx, @@ -537,7 +553,8 @@ ggml_tensor * conv_module(ggml_context * ctx, // from the transpose above. Reshape to 4D [T, 1, d_model, B] for // ggml_conv_2d_dw_direct which expects [W, H, C, N] (N == B, the // utterance batch). Kernel [k, 1, d_model] → [k, 1, 1, d_model]. - ggml_tensor * knl = ggml_reshape_4d(ctx, b.conv_dw_w, + ggml_tensor * knl = ggml_reshape_4d(ctx, + dw_kernel_for_direct(ctx, b.conv_dw_w), conv_kernel, 1, 1, d_model); ggml_tensor * data = ggml_reshape_4d(ctx, x, x->ne[0], 1, x->ne[1], B); @@ -1244,7 +1261,7 @@ ggml_tensor * build_pre_encode(ggml_context * ctx, // im2col path is used when direct_dw_in_pre_encode is false. x = pad_causal(x); if (policy.direct_dw_in_pre_encode) { - x = ggml_conv_2d_dw_direct(ctx, pe.conv2_w, x, + x = ggml_conv_2d_dw_direct(ctx, dw_kernel_for_direct(ctx, pe.conv2_w), x, /*s0=*/2, /*s1=*/2, /*p0=*/pe_p_op, /*p1=*/pe_p_op, /*d0=*/1, /*d1=*/1); @@ -1272,7 +1289,7 @@ ggml_tensor * build_pre_encode(ggml_context * ctx, // conv5 (depthwise) -> conv6 (pointwise) -> ReLU x = pad_causal(x); if (policy.direct_dw_in_pre_encode) { - x = ggml_conv_2d_dw_direct(ctx, pe.conv5_w, x, + x = ggml_conv_2d_dw_direct(ctx, dw_kernel_for_direct(ctx, pe.conv5_w), x, /*s0=*/2, /*s1=*/2, /*p0=*/pe_p_op, /*p1=*/pe_p_op, /*d0=*/1, /*d1=*/1); diff --git a/src/transcribe-loader.cpp b/src/transcribe-loader.cpp index e1370130..d944bdf9 100644 --- a/src/transcribe-loader.cpp +++ b/src/transcribe-loader.cpp @@ -122,6 +122,20 @@ transcribe_status Loader::open(const char * path) { break; } + // Copy every scalar-string KV into the metadata map. This mirrors + // llama.cpp's generic metadata accessor: the public API exposes one + // keyed getter (transcribe_model_meta_val_str) instead of a typed + // accessor per field, so adding a new string KV in the converter needs + // no API change. Non-string KVs (hparams, arrays such as the token + // list) are intentionally skipped — this is identity/display metadata. + const int64_t n_kv = gguf_get_n_kv(gguf_); + for (int64_t i = 0; i < n_kv; ++i) { + if (gguf_get_kv_type(gguf_, i) != GGUF_TYPE_STRING) { + continue; + } + meta_.emplace(gguf_get_key(gguf_, i), gguf_get_val_str(gguf_, i)); + } + return TRANSCRIBE_OK; } diff --git a/src/transcribe-loader.h b/src/transcribe-loader.h index b6bcc806..d768b26a 100644 --- a/src/transcribe-loader.h +++ b/src/transcribe-loader.h @@ -20,6 +20,7 @@ #include "transcribe.h" +#include #include struct gguf_context; @@ -69,6 +70,11 @@ class Loader { const std::string & arch() const { return arch_; } const std::string & variant() const { return variant_; } + // All scalar-string metadata KVs read on open(), keyed by GGUF key. + // Copied onto the model after dispatch and surfaced publicly via + // transcribe_model_meta_val_str(). Never affects dispatch. + const std::map & meta() const { return meta_; } + // Borrowed pointer to the underlying gguf_context. Valid until the // Loader is destroyed or release_gguf() is called. Returns nullptr // before a successful open(). @@ -83,6 +89,7 @@ class Loader { std::string path_; std::string arch_; std::string variant_; + std::map meta_; gguf_context * gguf_ = nullptr; }; diff --git a/src/transcribe-meta.cpp b/src/transcribe-meta.cpp index cd519371..9dece306 100644 --- a/src/transcribe-meta.cpp +++ b/src/transcribe-meta.cpp @@ -396,17 +396,47 @@ transcribe_status read_languages_kv(const gguf_context * gguf, // Information gap, not a claim that the model has no // languages. Caller has already pre-populated // (n_languages = 0, languages = nullptr). - return TRANSCRIBE_OK; + break; case KvResult::Ok: // set_languages copies the strings into the model so // their c_str() pointers stay valid for the model's // lifetime. model.set_languages(std::move(langs)); - return TRANSCRIBE_OK; + break; case KvResult::BadType: return TRANSCRIBE_ERR_GGUF; } - return TRANSCRIBE_ERR_GGUF; // unreachable + + // Translation-target set: the target-side twin of general.languages, + // installed the same way (model-owned storage, republished caps + // pointer). Absent on ASR-only models and on GGUFs predating the KV, + // which leaves caps.n_translate_target_languages = 0 ("not advertised"). + std::vector targets; + switch (read_string_array_kv(gguf, "stt.translation.target_languages", targets)) { + case KvResult::Absent: + break; + case KvResult::Ok: + model.set_translate_target_languages(std::move(targets)); + break; + case KvResult::BadType: + return TRANSCRIBE_ERR_GGUF; + } + + // Optional translation-pair contract. When present, the generic validator + // can reject explicitly supplied unsupported source>target pairs before + // family dispatch. Absent keeps old GGUFs permissive. + std::vector pairs; + switch (read_string_array_kv(gguf, "stt.translation.pairs", pairs)) { + case KvResult::Absent: + break; + case KvResult::Ok: + model.set_translation_pairs(std::move(pairs)); + break; + case KvResult::BadType: + return TRANSCRIBE_ERR_GGUF; + } + + return TRANSCRIBE_OK; } } // namespace transcribe diff --git a/src/transcribe-meta.h b/src/transcribe-meta.h index edc0ec2b..dc282723 100644 --- a/src/transcribe-meta.h +++ b/src/transcribe-meta.h @@ -183,17 +183,18 @@ transcribe_status read_capability_kv(const gguf_context * gguf, transcribe_capabilities & caps); // Read general.languages (string array of BCP-47-ish short codes) and -// install it on the model via transcribe_model::set_languages(). On -// Absent the model's language list is left unchanged (the caller is -// expected to pre-populate it as zero / nullptr — see the -// "information gap, not a claim" comment in arch/parakeet/model.cpp). +// install it on the model via transcribe_model::set_languages(); then +// likewise read optional stt.translation.target_languages and +// stt.translation.pairs into model-owned storage for the TRANSLATE target and +// pair gates. On Absent each list is left unchanged (the caller is expected to +// pre-populate it as zero / nullptr — see the "information gap, not a claim" +// comment in arch/parakeet/model.cpp). // // Returns: // TRANSCRIBE_OK on success or absent. // TRANSCRIBE_ERR_INVALID_ARG if gguf is null. -// TRANSCRIBE_ERR_GGUF if general.languages is present but is -// not a string array, or any element is -// null. +// TRANSCRIBE_ERR_GGUF if a recognized array is present but is +// not a string array, or any element is null. transcribe_status read_languages_kv(const gguf_context * gguf, transcribe_model & model); diff --git a/src/transcribe-model.cpp b/src/transcribe-model.cpp index e544380a..eae9f88b 100644 --- a/src/transcribe-model.cpp +++ b/src/transcribe-model.cpp @@ -72,3 +72,41 @@ void transcribe_model::set_languages(std::vector langs) { caps.n_languages = static_cast(language_storage_.size()); caps.languages = language_ptrs_.empty() ? nullptr : language_ptrs_.data(); } + +void transcribe_model::set_translate_target_languages(std::vector langs) { + // Same discipline as set_languages(): move strings into the model, + // rebuild the pointer vector, then publish count + pointer together. + translate_target_storage_ = std::move(langs); + + translate_target_ptrs_.clear(); + translate_target_ptrs_.reserve(translate_target_storage_.size()); + for (const auto & s : translate_target_storage_) { + translate_target_ptrs_.push_back(s.c_str()); + } + + caps.n_translate_target_languages = + static_cast(translate_target_storage_.size()); + caps.translate_target_languages = + translate_target_ptrs_.empty() ? nullptr : translate_target_ptrs_.data(); +} + +void transcribe_model::set_translation_pairs(std::vector pairs) { + translation_pair_storage_ = std::move(pairs); +} + +bool transcribe_model::allows_translation_pair(const char * src, + const char * dst) const { + if (translation_pair_storage_.empty()) { + return true; + } + if (src == nullptr || dst == nullptr || src[0] == '\0' || dst[0] == '\0') { + return false; + } + const std::string pair = std::string(src) + ">" + dst; + for (const auto & allowed : translation_pair_storage_) { + if (allowed == pair) { + return true; + } + } + return false; +} diff --git a/src/transcribe-model.h b/src/transcribe-model.h index 185cfd89..b0cc4d49 100644 --- a/src/transcribe-model.h +++ b/src/transcribe-model.h @@ -29,6 +29,7 @@ #include "transcribe.h" #include +#include #include #include @@ -56,6 +57,14 @@ struct transcribe_model { // stt.variant was absent; the family supplies a default). std::string variant; + // All scalar-string GGUF metadata KVs (general.*, stt.variant, + // tokenizer.ggml.model, chat_template, ...), keyed by GGUF key. Copied + // from the loader right after the per-family load() returns — the loader + // outlives that call, so no family handler has to populate this. Exposed + // read-only via transcribe_model_meta_val_str(); this mirrors llama.cpp's + // single generic metadata accessor rather than a typed accessor per field. + std::map meta; + // Runtime backend currently bound to this model. Empty string means // "no backend bound" — both pre-binding and the model == NULL case // collapse to that one stable rule. See the public header for the @@ -158,12 +167,32 @@ struct transcribe_model { // calls this once after deciding the language list. void set_languages(std::vector langs); + // Replace the translation-target language list (the set valid for + // run_params::target_language under TRANSCRIBE_TASK_TRANSLATE). Same + // model-owned-storage discipline as set_languages(): copies the + // strings in and republishes caps.translate_target_languages + count. + void set_translate_target_languages(std::vector langs); + + // Optional translation pair contract, stored as "src>dst" strings from + // stt.translation.pairs. Empty means "not advertised" and leaves generic + // pair validation inert. + void set_translation_pairs(std::vector pairs); + bool allows_translation_pair(const char * src, const char * dst) const; + private: // Backing storage for the languages chain. Kept private so the only // way to mutate it is through set_languages(), which guarantees the // capability struct's pointer + count stay in sync. std::vector language_storage_; std::vector language_ptrs_; + + // Backing storage for the translation-target chain, mutated only via + // set_translate_target_languages() for the same sync guarantee. + std::vector translate_target_storage_; + std::vector translate_target_ptrs_; + + // Backing storage for optional generic translation pair validation. + std::vector translation_pair_storage_; }; namespace transcribe { diff --git a/src/transcribe.cpp b/src/transcribe.cpp index 0b30593a..f8a0cefd 100644 --- a/src/transcribe.cpp +++ b/src/transcribe.cpp @@ -291,6 +291,43 @@ transcribe_status validate_run_params_common( return TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE; } } + // Translation-target gate: for a TRANSLATE request naming a target + // language, reject up front when the model advertises a target set + // (n > 0) that does not include it. Mirrors the source-language check + // above and returns the same code. An empty/unadvertised set (old + // GGUFs, ASR-only models) makes this inert; family-level target/pair + // checks (e.g. canary's pivot pairs) still apply on top. + if (params->task == TRANSCRIBE_TASK_TRANSLATE && + params->target_language != nullptr && + session->model->caps.n_translate_target_languages > 0 && + session->model->caps.translate_target_languages != nullptr) + { + bool found = false; + for (int i = 0; i < session->model->caps.n_translate_target_languages; ++i) { + const char * entry = + session->model->caps.translate_target_languages[i]; + if (entry != nullptr && + std::strcmp(entry, params->target_language) == 0) { + found = true; + break; + } + } + if (!found) { + return TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE; + } + } + // Translation-pair gate: when a GGUF advertises exact src>dst pairs and + // the caller supplied both sides, reject unsupported directions before + // family dispatch. A missing source hint keeps this inert because most + // families do not perform generic language detection here. + if (params->task == TRANSCRIBE_TASK_TRANSLATE && + params->language != nullptr && + params->target_language != nullptr && + !session->model->allows_translation_pair(params->language, + params->target_language)) + { + return TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE; + } return TRANSCRIBE_OK; } @@ -1524,7 +1561,17 @@ extern "C" transcribe_status transcribe_model_load_file( // Hand off. The handler may take ownership of the gguf_context via // loader.release_gguf(); if it doesn't, the Loader destructor frees // it normally on stack unwinding. - return arch->load(loader, params, out_model); + const transcribe_status st = arch->load(loader, params, out_model); + + // Copy the string-metadata map onto the model once, centrally. The + // loader read it during open() and outlives arch->load(), so we set it + // here instead of in every per-family handler. `variant` is owned by the + // family (it may default it when stt.variant was absent) and stays on its + // own accessor; this map is the generic general.* / display surface. + if (st == TRANSCRIBE_OK && out_model != nullptr && *out_model != nullptr) { + (*out_model)->meta = loader.meta(); + } + return st; } extern "C" void transcribe_model_free(struct transcribe_model * model) { @@ -2649,6 +2696,19 @@ extern "C" const char * transcribe_model_variant_string(const struct transcribe_ return model->variant.c_str(); } +extern "C" const char * transcribe_model_meta_val_str( + const struct transcribe_model * model, const char * key) { + // Generic GGUF string-metadata getter, mirroring llama_model_meta_val_str. + // Returns a model-owned string (valid until the model is freed) or "" when + // the model/key is null or the key is absent. There is no fallback to the + // variant slug — callers that want one read transcribe_model_variant_string. + if (model == nullptr || key == nullptr) { + return ""; + } + const auto it = model->meta.find(key); + return it != model->meta.end() ? it->second.c_str() : ""; +} + extern "C" int transcribe_tokenize( const struct transcribe_model * model, const char * text, diff --git a/tests/fixtures/make_gguf_fixtures.py b/tests/fixtures/make_gguf_fixtures.py index 6ce95d58..a8852145 100644 --- a/tests/fixtures/make_gguf_fixtures.py +++ b/tests/fixtures/make_gguf_fixtures.py @@ -1430,6 +1430,35 @@ def emit_fixtures(out_dir: Path) -> None: ), ) + # Translation-capability KV override. Same toy parakeet vocabulary, + # hparams, and tensor catalog — but carries stt.capability.translate + # = true. The parakeet family default is supports_translate=false, so + # this fixture pins that the loader reads the canonical capability KV + # and flips the flag on. It also carries target-language and pair + # metadata for the shared TRANSLATE validation gates. + _write( + out_dir / "tokenizer_minimal_translate.gguf", + _build_full_gguf( + GGUF_MAGIC, + [ + _pack_kv_string("general.architecture", "parakeet"), + _pack_kv_string("stt.variant", "tdt-0.6b-translate-toy"), + _pack_kv_bool("stt.capability.translate", True), + _pack_kv_array_string( + "stt.translation.target_languages", + ["en", "de", "fr"], + ), + _pack_kv_array_string( + "stt.translation.pairs", + ["en>de", "de>en"], + ), + *tokenizer_kv, + *parakeet_hparams_kv, + ], + parakeet_tensors, + ), + ) + # Cache-aware streaming variant (ChunkedLimited, nemotron-style). # Adds the att_context_style + flat (left, right) menu so the # parakeet stream_begin hook routes into the cache-aware path and diff --git a/tests/golden/canary/canary-180m-flash.manifest.json b/tests/golden/canary/canary-180m-flash.manifest.json index 33bdd6a6..5db72c5e 100644 --- a/tests/golden/canary/canary-180m-flash.manifest.json +++ b/tests/golden/canary/canary-180m-flash.manifest.json @@ -39,12 +39,13 @@ ], "language_detection": false, "translation": true, + "translation_target_languages": ["en", "de", "es", "fr"], + "translation_pairs": ["en>de", "de>en", "en>es", "es>en", "en>fr", "fr>en"], "timestamps": [ "word", "segment" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "multitask_default": { diff --git a/tests/golden/canary/canary-1b-flash.manifest.json b/tests/golden/canary/canary-1b-flash.manifest.json index 563891b7..73d48652 100644 --- a/tests/golden/canary/canary-1b-flash.manifest.json +++ b/tests/golden/canary/canary-1b-flash.manifest.json @@ -43,12 +43,13 @@ ], "language_detection": false, "translation": true, + "translation_target_languages": ["en", "de", "es", "fr"], + "translation_pairs": ["en>de", "de>en", "en>es", "es>en", "en>fr", "fr>en"], "timestamps": [ "word", "segment" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "multitask_default": { diff --git a/tests/golden/canary/canary-1b-v2.manifest.json b/tests/golden/canary/canary-1b-v2.manifest.json index 0465c76b..438ddc34 100644 --- a/tests/golden/canary/canary-1b-v2.manifest.json +++ b/tests/golden/canary/canary-1b-v2.manifest.json @@ -60,12 +60,85 @@ ], "language_detection": false, "translation": true, + "translation_target_languages": [ + "bg", + "hr", + "cs", + "da", + "nl", + "en", + "et", + "fi", + "fr", + "de", + "el", + "hu", + "it", + "lt", + "mt", + "pl", + "pt", + "ro", + "sk", + "sl", + "es", + "sv", + "ru", + "uk" + ], + "translation_pairs": [ + "en>bg", + "bg>en", + "en>hr", + "hr>en", + "en>cs", + "cs>en", + "en>da", + "da>en", + "en>nl", + "nl>en", + "en>et", + "et>en", + "en>fi", + "fi>en", + "en>fr", + "fr>en", + "en>de", + "de>en", + "en>el", + "el>en", + "en>hu", + "hu>en", + "en>it", + "it>en", + "en>lt", + "lt>en", + "en>mt", + "mt>en", + "en>pl", + "pl>en", + "en>pt", + "pt>en", + "en>ro", + "ro>en", + "en>sk", + "sk>en", + "en>sl", + "sl>en", + "en>es", + "es>en", + "en>sv", + "sv>en", + "en>ru", + "ru>en", + "en>uk", + "uk>en" + ], "timestamps": [ "word", "segment" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "multitask_default": { diff --git a/tests/golden/canary/canary-1b.manifest.json b/tests/golden/canary/canary-1b.manifest.json index 14d7b6e6..2ac5ffab 100644 --- a/tests/golden/canary/canary-1b.manifest.json +++ b/tests/golden/canary/canary-1b.manifest.json @@ -34,9 +34,10 @@ "languages": ["en", "de", "es", "fr"], "language_detection": false, "translation": true, + "translation_target_languages": ["en", "de", "es", "fr"], + "translation_pairs": ["en>de", "de>en", "en>es", "es>en", "en>fr", "fr>en"], "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "multitask_default": { diff --git a/tests/golden/canary_qwen/canary-qwen-2.5b.manifest.json b/tests/golden/canary_qwen/canary-qwen-2.5b.manifest.json index 566e0d50..5a48da64 100644 --- a/tests/golden/canary_qwen/canary-qwen-2.5b.manifest.json +++ b/tests/golden/canary_qwen/canary-qwen-2.5b.manifest.json @@ -42,7 +42,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/canary_qwen.json", diff --git a/tests/golden/cohere/cohere-transcribe-03-2026.manifest.json b/tests/golden/cohere/cohere-transcribe-03-2026.manifest.json index 5f9eebca..d51a1678 100644 --- a/tests/golden/cohere/cohere-transcribe-03-2026.manifest.json +++ b/tests/golden/cohere/cohere-transcribe-03-2026.manifest.json @@ -36,7 +36,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/cohere.json", diff --git a/tests/golden/funasr_nano/fun-asr-mlt-nano-2512.manifest.json b/tests/golden/funasr_nano/fun-asr-mlt-nano-2512.manifest.json index 2b34ee0e..5a82cc0a 100644 --- a/tests/golden/funasr_nano/fun-asr-mlt-nano-2512.manifest.json +++ b/tests/golden/funasr_nano/fun-asr-mlt-nano-2512.manifest.json @@ -45,7 +45,6 @@ "translation": false, "timestamps": ["none"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/funasr_nano.json", diff --git a/tests/golden/funasr_nano/fun-asr-nano-2512.manifest.json b/tests/golden/funasr_nano/fun-asr-nano-2512.manifest.json index 91549f33..6017a546 100644 --- a/tests/golden/funasr_nano/fun-asr-nano-2512.manifest.json +++ b/tests/golden/funasr_nano/fun-asr-nano-2512.manifest.json @@ -38,7 +38,6 @@ "translation": false, "timestamps": ["none"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/funasr_nano.json", diff --git a/tests/golden/gigaam/gigaam-v3-ctc.manifest.json b/tests/golden/gigaam/gigaam-v3-ctc.manifest.json index 16fd015e..b603206e 100644 --- a/tests/golden/gigaam/gigaam-v3-ctc.manifest.json +++ b/tests/golden/gigaam/gigaam-v3-ctc.manifest.json @@ -44,7 +44,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "head_type": "ctc", diff --git a/tests/golden/gigaam/gigaam-v3-e2e-ctc.manifest.json b/tests/golden/gigaam/gigaam-v3-e2e-ctc.manifest.json index 3c2b8c1d..f043fdea 100644 --- a/tests/golden/gigaam/gigaam-v3-e2e-ctc.manifest.json +++ b/tests/golden/gigaam/gigaam-v3-e2e-ctc.manifest.json @@ -43,7 +43,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "head_type": "ctc", diff --git a/tests/golden/gigaam/gigaam-v3-e2e-rnnt.manifest.json b/tests/golden/gigaam/gigaam-v3-e2e-rnnt.manifest.json index 0f3041f5..706f96a0 100644 --- a/tests/golden/gigaam/gigaam-v3-e2e-rnnt.manifest.json +++ b/tests/golden/gigaam/gigaam-v3-e2e-rnnt.manifest.json @@ -43,7 +43,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "head_type": "rnnt", diff --git a/tests/golden/gigaam/gigaam-v3-rnnt.manifest.json b/tests/golden/gigaam/gigaam-v3-rnnt.manifest.json index dda8aa6d..7b3d1170 100644 --- a/tests/golden/gigaam/gigaam-v3-rnnt.manifest.json +++ b/tests/golden/gigaam/gigaam-v3-rnnt.manifest.json @@ -44,7 +44,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "head_type": "rnnt", diff --git a/tests/golden/granite/granite-4.0-1b-speech.manifest.json b/tests/golden/granite/granite-4.0-1b-speech.manifest.json index 948f4875..5782b8ca 100644 --- a/tests/golden/granite/granite-4.0-1b-speech.manifest.json +++ b/tests/golden/granite/granite-4.0-1b-speech.manifest.json @@ -40,9 +40,17 @@ "languages": ["en", "fr", "de", "es", "pt", "ja"], "language_detection": false, "translation": true, + "translation_target_languages": ["en", "fr", "de", "es", "pt", "ja", "it", "zh"], + "translation_pairs": [ + "en>fr", "fr>en", + "en>de", "de>en", + "en>es", "es>en", + "en>pt", "pt>en", + "en>ja", "ja>en", + "en>it", "en>zh" + ], "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/granite.json", diff --git a/tests/golden/granite/granite-speech-4.1-2b-plus.manifest.json b/tests/golden/granite/granite-speech-4.1-2b-plus.manifest.json index 2c2d90fc..bbc50f5e 100644 --- a/tests/golden/granite/granite-speech-4.1-2b-plus.manifest.json +++ b/tests/golden/granite/granite-speech-4.1-2b-plus.manifest.json @@ -45,7 +45,6 @@ "translation": false, "timestamps": ["word"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": true }, "tolerance_file": "tests/tolerances/granite.json", diff --git a/tests/golden/granite/granite-speech-4.1-2b.manifest.json b/tests/golden/granite/granite-speech-4.1-2b.manifest.json index 1545e702..85ced8c1 100644 --- a/tests/golden/granite/granite-speech-4.1-2b.manifest.json +++ b/tests/golden/granite/granite-speech-4.1-2b.manifest.json @@ -40,9 +40,17 @@ "languages": ["en", "fr", "de", "es", "pt", "ja"], "language_detection": false, "translation": true, + "translation_target_languages": ["en", "fr", "de", "es", "pt", "ja", "it", "zh"], + "translation_pairs": [ + "en>fr", "fr>en", + "en>de", "de>en", + "en>es", "es>en", + "en>pt", "pt>en", + "en>ja", "ja>en", + "en>it", "en>zh" + ], "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/granite.json", diff --git a/tests/golden/granite_nar/granite-speech-4.1-2b-nar.manifest.json b/tests/golden/granite_nar/granite-speech-4.1-2b-nar.manifest.json index aa710d2f..86713555 100644 --- a/tests/golden/granite_nar/granite-speech-4.1-2b-nar.manifest.json +++ b/tests/golden/granite_nar/granite-speech-4.1-2b-nar.manifest.json @@ -43,7 +43,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/granite_nar.json", diff --git a/tests/golden/medasr/medasr.manifest.json b/tests/golden/medasr/medasr.manifest.json index bdaf70ce..5d77272b 100644 --- a/tests/golden/medasr/medasr.manifest.json +++ b/tests/golden/medasr/medasr.manifest.json @@ -52,7 +52,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false, "comment": "Monolingual English ASR over a 105M Conformer-CTC. Model card publishes WER on internal medical-dictation datasets (RAD-DICT/GENERAL-DICT/FM-DICT) and on MIMIC Eye Gaze; no LibriSpeech number is reported. Stage 7 gates against the measured Oracle reference baseline on LibriSpeech test-clean — see reports/wer/medasr-REF.test-clean.{jsonl,score.json}." }, diff --git a/tests/golden/moonshine/moonshine-base.manifest.json b/tests/golden/moonshine/moonshine-base.manifest.json index 0fa7b08e..5109140a 100644 --- a/tests/golden/moonshine/moonshine-base.manifest.json +++ b/tests/golden/moonshine/moonshine-base.manifest.json @@ -41,7 +41,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/moonshine.json", diff --git a/tests/golden/moonshine/moonshine-tiny.manifest.json b/tests/golden/moonshine/moonshine-tiny.manifest.json index b304dc90..50293dbe 100644 --- a/tests/golden/moonshine/moonshine-tiny.manifest.json +++ b/tests/golden/moonshine/moonshine-tiny.manifest.json @@ -41,7 +41,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/moonshine.json", diff --git a/tests/golden/moonshine_streaming/moonshine-streaming-medium.manifest.json b/tests/golden/moonshine_streaming/moonshine-streaming-medium.manifest.json index 02499529..1e5cfffd 100644 --- a/tests/golden/moonshine_streaming/moonshine-streaming-medium.manifest.json +++ b/tests/golden/moonshine_streaming/moonshine-streaming-medium.manifest.json @@ -43,7 +43,6 @@ "translation": false, "timestamps": [], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/moonshine_streaming.json", diff --git a/tests/golden/moonshine_streaming/moonshine-streaming-small.manifest.json b/tests/golden/moonshine_streaming/moonshine-streaming-small.manifest.json index 77571686..8ebdb041 100644 --- a/tests/golden/moonshine_streaming/moonshine-streaming-small.manifest.json +++ b/tests/golden/moonshine_streaming/moonshine-streaming-small.manifest.json @@ -43,7 +43,6 @@ "translation": false, "timestamps": [], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/moonshine_streaming.json", diff --git a/tests/golden/moonshine_streaming/moonshine-streaming-tiny.manifest.json b/tests/golden/moonshine_streaming/moonshine-streaming-tiny.manifest.json index 9b7236a3..58a19073 100644 --- a/tests/golden/moonshine_streaming/moonshine-streaming-tiny.manifest.json +++ b/tests/golden/moonshine_streaming/moonshine-streaming-tiny.manifest.json @@ -41,7 +41,6 @@ "translation": false, "timestamps": [], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/moonshine_streaming.json", diff --git a/tests/golden/parakeet/nemotron-3.5-asr-streaming-0.6b.manifest.json b/tests/golden/parakeet/nemotron-3.5-asr-streaming-0.6b.manifest.json index 80d6775d..c00511e8 100644 --- a/tests/golden/parakeet/nemotron-3.5-asr-streaming-0.6b.manifest.json +++ b/tests/golden/parakeet/nemotron-3.5-asr-streaming-0.6b.manifest.json @@ -51,7 +51,6 @@ "word" ], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/nemotron-3.5-asr-streaming-0.6b.json", diff --git a/tests/golden/parakeet/nemotron-speech-streaming-en-0.6b.manifest.json b/tests/golden/parakeet/nemotron-speech-streaming-en-0.6b.manifest.json index 8a79e00e..ee883ead 100644 --- a/tests/golden/parakeet/nemotron-speech-streaming-en-0.6b.manifest.json +++ b/tests/golden/parakeet/nemotron-speech-streaming-en-0.6b.manifest.json @@ -43,7 +43,6 @@ "word" ], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/nemotron-speech-streaming-en-0.6b.json", diff --git a/tests/golden/parakeet/parakeet-ctc-0.6b.manifest.json b/tests/golden/parakeet/parakeet-ctc-0.6b.manifest.json index 2b160e47..68f5c81c 100644 --- a/tests/golden/parakeet/parakeet-ctc-0.6b.manifest.json +++ b/tests/golden/parakeet/parakeet-ctc-0.6b.manifest.json @@ -43,7 +43,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/parakeet.json", diff --git a/tests/golden/parakeet/parakeet-ctc-1.1b.manifest.json b/tests/golden/parakeet/parakeet-ctc-1.1b.manifest.json index 4d44b5f4..0d63f640 100644 --- a/tests/golden/parakeet/parakeet-ctc-1.1b.manifest.json +++ b/tests/golden/parakeet/parakeet-ctc-1.1b.manifest.json @@ -43,7 +43,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/parakeet.json", diff --git a/tests/golden/parakeet/parakeet-rnnt-0.6b.manifest.json b/tests/golden/parakeet/parakeet-rnnt-0.6b.manifest.json index 0590af5c..b81baf24 100644 --- a/tests/golden/parakeet/parakeet-rnnt-0.6b.manifest.json +++ b/tests/golden/parakeet/parakeet-rnnt-0.6b.manifest.json @@ -43,7 +43,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/parakeet.json", diff --git a/tests/golden/parakeet/parakeet-rnnt-1.1b.manifest.json b/tests/golden/parakeet/parakeet-rnnt-1.1b.manifest.json index 8562cf0c..bc7b7f49 100644 --- a/tests/golden/parakeet/parakeet-rnnt-1.1b.manifest.json +++ b/tests/golden/parakeet/parakeet-rnnt-1.1b.manifest.json @@ -43,7 +43,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/parakeet.json", diff --git a/tests/golden/parakeet/parakeet-tdt-0.6b-v2.manifest.json b/tests/golden/parakeet/parakeet-tdt-0.6b-v2.manifest.json index a10fb669..77064025 100644 --- a/tests/golden/parakeet/parakeet-tdt-0.6b-v2.manifest.json +++ b/tests/golden/parakeet/parakeet-tdt-0.6b-v2.manifest.json @@ -36,7 +36,6 @@ "translation": false, "timestamps": ["word", "token"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/parakeet.json", diff --git a/tests/golden/parakeet/parakeet-tdt-0.6b-v3.manifest.json b/tests/golden/parakeet/parakeet-tdt-0.6b-v3.manifest.json index d9995aee..44fd42c4 100644 --- a/tests/golden/parakeet/parakeet-tdt-0.6b-v3.manifest.json +++ b/tests/golden/parakeet/parakeet-tdt-0.6b-v3.manifest.json @@ -42,7 +42,6 @@ "translation": false, "timestamps": ["word", "token"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/parakeet.json", diff --git a/tests/golden/parakeet/parakeet-tdt-1.1b.manifest.json b/tests/golden/parakeet/parakeet-tdt-1.1b.manifest.json index dea1c4eb..3196eac6 100644 --- a/tests/golden/parakeet/parakeet-tdt-1.1b.manifest.json +++ b/tests/golden/parakeet/parakeet-tdt-1.1b.manifest.json @@ -44,7 +44,6 @@ "segment" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/parakeet.json", diff --git a/tests/golden/parakeet/parakeet-tdt_ctc-1.1b.manifest.json b/tests/golden/parakeet/parakeet-tdt_ctc-1.1b.manifest.json index 8e36e62b..8302fb73 100644 --- a/tests/golden/parakeet/parakeet-tdt_ctc-1.1b.manifest.json +++ b/tests/golden/parakeet/parakeet-tdt_ctc-1.1b.manifest.json @@ -44,7 +44,6 @@ "segment" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/parakeet.json", diff --git a/tests/golden/parakeet/parakeet-tdt_ctc-110m.manifest.json b/tests/golden/parakeet/parakeet-tdt_ctc-110m.manifest.json index 51a50442..32cecccc 100644 --- a/tests/golden/parakeet/parakeet-tdt_ctc-110m.manifest.json +++ b/tests/golden/parakeet/parakeet-tdt_ctc-110m.manifest.json @@ -44,7 +44,6 @@ "segment" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/parakeet.json", diff --git a/tests/golden/parakeet/parakeet-unified-en-0.6b.manifest.json b/tests/golden/parakeet/parakeet-unified-en-0.6b.manifest.json index 3139cc6b..76038a04 100644 --- a/tests/golden/parakeet/parakeet-unified-en-0.6b.manifest.json +++ b/tests/golden/parakeet/parakeet-unified-en-0.6b.manifest.json @@ -44,7 +44,6 @@ "word" ], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/parakeet.json", diff --git a/tests/golden/qwen3_asr/qwen3-asr-0.6b.manifest.json b/tests/golden/qwen3_asr/qwen3-asr-0.6b.manifest.json index b3241414..5ed42ec7 100644 --- a/tests/golden/qwen3_asr/qwen3-asr-0.6b.manifest.json +++ b/tests/golden/qwen3_asr/qwen3-asr-0.6b.manifest.json @@ -46,7 +46,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/qwen3_asr.json", diff --git a/tests/golden/qwen3_asr/qwen3-asr-1.7b.manifest.json b/tests/golden/qwen3_asr/qwen3-asr-1.7b.manifest.json index b7f24934..27bccbaa 100644 --- a/tests/golden/qwen3_asr/qwen3-asr-1.7b.manifest.json +++ b/tests/golden/qwen3_asr/qwen3-asr-1.7b.manifest.json @@ -73,7 +73,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/qwen3_asr-1.7b.json", diff --git a/tests/golden/sensevoice/sensevoice-small.manifest.json b/tests/golden/sensevoice/sensevoice-small.manifest.json index 77a8b016..714d986a 100644 --- a/tests/golden/sensevoice/sensevoice-small.manifest.json +++ b/tests/golden/sensevoice/sensevoice-small.manifest.json @@ -49,7 +49,6 @@ "translation": false, "timestamps": [], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/sensevoice.json", diff --git a/tests/golden/voxtral/voxtral-mini-3b-2507.manifest.json b/tests/golden/voxtral/voxtral-mini-3b-2507.manifest.json index ba131a77..cabe542b 100644 --- a/tests/golden/voxtral/voxtral-mini-3b-2507.manifest.json +++ b/tests/golden/voxtral/voxtral-mini-3b-2507.manifest.json @@ -40,9 +40,9 @@ "languages": ["en", "fr", "de", "es", "it", "pt", "nl", "hi"], "language_detection": true, "translation": true, + "translation_target_languages": ["en", "fr", "de", "es", "it", "pt", "nl", "hi"], "timestamps": ["none"], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/voxtral.json", diff --git a/tests/golden/voxtral_realtime/voxtral-mini-4b-realtime-2602.manifest.json b/tests/golden/voxtral_realtime/voxtral-mini-4b-realtime-2602.manifest.json index 8924c963..4fa8780d 100644 --- a/tests/golden/voxtral_realtime/voxtral-mini-4b-realtime-2602.manifest.json +++ b/tests/golden/voxtral_realtime/voxtral-mini-4b-realtime-2602.manifest.json @@ -43,7 +43,6 @@ "translation": false, "timestamps": ["none"], "streaming": true, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/voxtral_realtime.json", diff --git a/tests/golden/whisper/whisper-base.en.manifest.json b/tests/golden/whisper/whisper-base.en.manifest.json index 1faf9b55..65881275 100644 --- a/tests/golden/whisper/whisper-base.en.manifest.json +++ b/tests/golden/whisper/whisper-base.en.manifest.json @@ -51,7 +51,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/whisper-base.en.json", diff --git a/tests/golden/whisper/whisper-base.manifest.json b/tests/golden/whisper/whisper-base.manifest.json index 4f5ea32c..c8c20bdf 100644 --- a/tests/golden/whisper/whisper-base.manifest.json +++ b/tests/golden/whisper/whisper-base.manifest.json @@ -143,12 +143,12 @@ ], "language_detection": true, "translation": true, + "translation_target_languages": ["en"], "timestamps": [ "segment", "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/whisper-base.json", diff --git a/tests/golden/whisper/whisper-large-v2.manifest.json b/tests/golden/whisper/whisper-large-v2.manifest.json index 213945f4..11a6ec07 100644 --- a/tests/golden/whisper/whisper-large-v2.manifest.json +++ b/tests/golden/whisper/whisper-large-v2.manifest.json @@ -143,12 +143,12 @@ ], "language_detection": true, "translation": true, + "translation_target_languages": ["en"], "timestamps": [ "segment", "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/whisper-large-v2.json", diff --git a/tests/golden/whisper/whisper-large-v3-turbo.manifest.json b/tests/golden/whisper/whisper-large-v3-turbo.manifest.json index 88ed66c7..913a4998 100644 --- a/tests/golden/whisper/whisper-large-v3-turbo.manifest.json +++ b/tests/golden/whisper/whisper-large-v3-turbo.manifest.json @@ -144,12 +144,12 @@ ], "language_detection": true, "translation": true, + "translation_target_languages": ["en"], "timestamps": [ "segment", "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/whisper-large-v3-turbo.json", diff --git a/tests/golden/whisper/whisper-large-v3.manifest.json b/tests/golden/whisper/whisper-large-v3.manifest.json index 09ebab4a..31c83005 100644 --- a/tests/golden/whisper/whisper-large-v3.manifest.json +++ b/tests/golden/whisper/whisper-large-v3.manifest.json @@ -144,12 +144,12 @@ ], "language_detection": true, "translation": true, + "translation_target_languages": ["en"], "timestamps": [ "segment", "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/whisper-large-v3.json", diff --git a/tests/golden/whisper/whisper-large.manifest.json b/tests/golden/whisper/whisper-large.manifest.json index 97fbbb80..906780be 100644 --- a/tests/golden/whisper/whisper-large.manifest.json +++ b/tests/golden/whisper/whisper-large.manifest.json @@ -143,12 +143,12 @@ ], "language_detection": true, "translation": true, + "translation_target_languages": ["en"], "timestamps": [ "segment", "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/whisper-large.json", diff --git a/tests/golden/whisper/whisper-medium.en.manifest.json b/tests/golden/whisper/whisper-medium.en.manifest.json index f5dcc1fe..f4b376f0 100644 --- a/tests/golden/whisper/whisper-medium.en.manifest.json +++ b/tests/golden/whisper/whisper-medium.en.manifest.json @@ -50,7 +50,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/whisper-medium.en.json", diff --git a/tests/golden/whisper/whisper-medium.manifest.json b/tests/golden/whisper/whisper-medium.manifest.json index 10ae6402..c48f2902 100644 --- a/tests/golden/whisper/whisper-medium.manifest.json +++ b/tests/golden/whisper/whisper-medium.manifest.json @@ -143,12 +143,12 @@ ], "language_detection": true, "translation": true, + "translation_target_languages": ["en"], "timestamps": [ "segment", "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/whisper-medium.json", diff --git a/tests/golden/whisper/whisper-small.en.manifest.json b/tests/golden/whisper/whisper-small.en.manifest.json index 3178b7da..b1b4656e 100644 --- a/tests/golden/whisper/whisper-small.en.manifest.json +++ b/tests/golden/whisper/whisper-small.en.manifest.json @@ -51,7 +51,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/whisper-small.en.json", diff --git a/tests/golden/whisper/whisper-small.manifest.json b/tests/golden/whisper/whisper-small.manifest.json index c552f05d..7bbd47a1 100644 --- a/tests/golden/whisper/whisper-small.manifest.json +++ b/tests/golden/whisper/whisper-small.manifest.json @@ -143,12 +143,12 @@ ], "language_detection": true, "translation": true, + "translation_target_languages": ["en"], "timestamps": [ "segment", "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/whisper-small.json", diff --git a/tests/golden/whisper/whisper-tiny.en.manifest.json b/tests/golden/whisper/whisper-tiny.en.manifest.json index 3526f0df..682002ab 100644 --- a/tests/golden/whisper/whisper-tiny.en.manifest.json +++ b/tests/golden/whisper/whisper-tiny.en.manifest.json @@ -51,7 +51,6 @@ "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/whisper-tiny.en.json", diff --git a/tests/golden/whisper/whisper-tiny.manifest.json b/tests/golden/whisper/whisper-tiny.manifest.json index 8409be73..f6c3776a 100644 --- a/tests/golden/whisper/whisper-tiny.manifest.json +++ b/tests/golden/whisper/whisper-tiny.manifest.json @@ -143,12 +143,12 @@ ], "language_detection": true, "translation": true, + "translation_target_languages": ["en"], "timestamps": [ "segment", "word" ], "streaming": false, - "voice_activity_detection": false, "speaker_diarization": false }, "tolerance_file": "tests/tolerances/whisper-tiny.json", diff --git a/tests/stream_capability_unit.cpp b/tests/stream_capability_unit.cpp index 7c30ea77..5e99fe05 100644 --- a/tests/stream_capability_unit.cpp +++ b/tests/stream_capability_unit.cpp @@ -34,6 +34,7 @@ #include #include +#include #include #ifndef TRANSCRIBE_TEST_FIXTURES_DIR @@ -232,12 +233,77 @@ void test_run_after_failed_begin_does_not_get_stuck() { transcribe_model_free(model); } +// Translation-capability KV override. The fixture carries +// stt.capability.translate=true. The parakeet family default is +// supports_translate=false, so a passing read must flip the flag to +// true. Pins that read_capability_kv reads the canonical translate KV — +// the key the granite / medasr / granite_nar converters now emit. The +// original bug: those converters wrote a misspelled stt.capability. +// translation that the loader never read, so granite -plus advertised +// translation it should not have. +void test_supports_translate_kv_override() { + struct transcribe_model * model = nullptr; + struct transcribe_session * ctx = nullptr; + if (!load_and_init("tokenizer_minimal_translate.gguf", &model, &ctx)) { + return; + } + + transcribe_capabilities caps_buf; transcribe_capabilities_init(&caps_buf); + const bool caps_ok = + transcribe_model_get_capabilities(model, &caps_buf) == TRANSCRIBE_OK; + const transcribe_capabilities * caps = caps_ok ? &caps_buf : nullptr; + CHECK(caps != nullptr); + if (caps != nullptr) { + // Canonical KV honored: family default false, KV says true. + CHECK(caps->supports_translate == true); + // stt.translation.target_languages is read into the model and + // exposed on the public caps struct (the target-side twin of + // languages[]). + CHECK(caps->n_translate_target_languages == 3); + if (caps->n_translate_target_languages == 3 && + caps->translate_target_languages != nullptr) { + CHECK(std::strcmp(caps->translate_target_languages[0], "en") == 0); + CHECK(std::strcmp(caps->translate_target_languages[1], "de") == 0); + CHECK(std::strcmp(caps->translate_target_languages[2], "fr") == 0); + } + } + + // Translation-target gate: a TRANSLATE request whose target_language is + // absent from the advertised set is rejected up front with + // UNSUPPORTED_LANGUAGE ("zz" is not in {"en"}). This fires in the + // shared validate_run_params_common before any family compute. + { + transcribe_run_params rp; transcribe_run_params_init(&rp); + rp.task = TRANSCRIBE_TASK_TRANSLATE; + rp.target_language = "zz"; + const float pcm[16] = { 0.0f }; + CHECK(transcribe_run(ctx, pcm, 16, &rp) == + TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE); + } + + // Translation-pair gate: "fr" is an advertised target, so the target + // gate passes, but the exact pair set only allows en>de and de>en. + { + transcribe_run_params rp; transcribe_run_params_init(&rp); + rp.task = TRANSCRIBE_TASK_TRANSLATE; + rp.language = "de"; + rp.target_language = "fr"; + const float pcm[16] = { 0.0f }; + CHECK(transcribe_run(ctx, pcm, 16, &rp) == + TRANSCRIBE_ERR_UNSUPPORTED_LANGUAGE); + } + + transcribe_session_free(ctx); + transcribe_model_free(model); +} + } // namespace int main() { test_supports_streaming_false(); test_supports_streaming_true_variant_offline(); test_run_after_failed_begin_does_not_get_stuck(); + test_supports_translate_kv_override(); if (g_failures > 0) { std::fprintf(stderr, "stream_capability_unit: %d failures\n",