diff --git a/docs/models/granite-speech-4.1-2b-plus.md b/docs/models/granite-speech-4.1-2b-plus.md index 8d725812..5f94649e 100644 --- a/docs/models/granite-speech-4.1-2b-plus.md +++ b/docs/models/granite-speech-4.1-2b-plus.md @@ -12,9 +12,10 @@ final-layer hidden states (doubling the projector K/V input from 1024 to Offline multilingual speech-to-text with word-level timestamps. Covers English plus French, German, Spanish, and Portuguese (no Japanese on this -variant). Takes a 16 kHz mono WAV and produces a transcript, optionally -interleaved with `[SS:N]` word-timestamp markers (centiseconds since -segment start). +variant). Takes a 16 kHz mono WAV and produces a transcript; with +`--timestamps word` it returns per-word start/end times. Internally the model +emits `[T:N]` end-of-word centisecond markers; the runtime parses them into +structured word timestamps and returns a clean transcript. This variant is transcription-only. Unlike the base [`granite-speech-4.1-2b`](granite-speech-4.1-2b.md), it does not perform @@ -74,11 +75,20 @@ build/bin/transcribe-cli \ samples/jfk.wav ``` -Output includes `[SS:N]` markers (centiseconds since segment start) -interleaved with words: +The runtime returns a clean transcript plus structured per-word start/end +times. (Internally the model emits `[T:N]` markers giving each word's end time +in centiseconds, modulo 1000 with a 10 s rollover that the runtime unwraps; the +markers are stripped from the returned text.) ``` -[SS:23] And [SS:52] so [SS:92] my [SS:121] fellow [SS:154] Americans ... +text: and so my fellow americans ask not what your country can do for you ask what you can do for your country +words: 22 + [ 0.30 -> 0.57] and + [ 0.57 -> 0.95] so + [ 0.95 -> 1.25] my + [ 1.25 -> 1.60] fellow + [ 1.60 -> 2.13] americans + ... ``` ## Performance @@ -143,7 +153,7 @@ Linux 6.18 (Fedora 43), transcribe.cpp `dbe5814`. | Transcribe (English) | Yes | | Transcribe (fr/de/es/pt) | Yes | | 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) | +| Word-level timestamps | Yes (`--timestamps word`, structured per-word t0/t1 parsed from the model's `[T:N]` markers) | | Speaker diarization | No (upstream supports via prompt; not exposed in v1 of transcribe.cpp) | ## Numerical Validation diff --git a/docs/models/granite-speech.md b/docs/models/granite-speech.md index 2e95788c..5b2be93d 100644 --- a/docs/models/granite-speech.md +++ b/docs/models/granite-speech.md @@ -22,7 +22,7 @@ perspective they are one family of GGUFs. LLM head, autoregressive; 1.31% BF16 / 1.32% Q8_0 WER on LibriSpeech test-clean. Improved punctuation and casing over 4.0-1b. - **Most features.** `granite-speech-4.1-2b-plus` — same 4.1 base plus - word-level timestamps (`[SS:N]` centisecond markers). Encoder + word-level timestamps (parsed from the model's `[T:N]` centisecond markers). Encoder concatenates a mid-layer and the final layer (`cat_hidden_layers=[3]`), doubling projector K/V width. - **Fastest, ASR-only.** `granite-speech-4.1-2b-nar` — non-autoregressive @@ -101,8 +101,8 @@ Translation (`granite-4.0-1b-speech`, `granite-speech-4.1-2b`): 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 - with words (`--timestamps word`). +- **Word-level timestamps** via `--timestamps word` — structured per-word + start/end times, parsed from the model's `[T:N]` centisecond markers. NAR only (`granite-speech-4.1-2b-nar`): - **Single-pass non-autoregressive decode** — fastest of the four. diff --git a/docs/models/nemotron-3.5-asr-streaming-0.6b.md b/docs/models/nemotron-3.5-asr-streaming-0.6b.md index 2d49c178..8dbaaedd 100644 --- a/docs/models/nemotron-3.5-asr-streaming-0.6b.md +++ b/docs/models/nemotron-3.5-asr-streaming-0.6b.md @@ -74,15 +74,15 @@ context, NVIDIA's self-reported FLEURS en-US WER is **7.91%** (and an | --- | ---: | ---: | | Reference (NeMo) | 7.99 | 3.03 | | F32 | 7.97 | 3.04 | -| F16 | 7.97 | 3.04 | -| Q8_0 | 7.88 | 3.05 | -| Q6_K | 8.02 | 3.08 | +| F16 | 7.97 | 3.03 | +| Q8_0 | 7.88 | 3.06 | +| Q6_K | 8.02 | 3.07 | | Q5_K_M | 8.15 | 3.10 | -| Q4_K_M | 8.49 | 3.30 | +| Q4_K_M | 8.49 | 3.28 | The F32 reference dtype meets the measured-Oracle gate on both datasets. F16/Q8_0/Q6_K/Q5_K_M land inside the reference 95% CI; Q4_K_M carries the -largest quantization loss (+0.50 on FLEURS, +0.27 on LibriSpeech) but is +largest quantization loss (+0.50 on FLEURS, +0.25 on LibriSpeech) but is accepted for shipping. ## Quick Start diff --git a/docs/porting/families/granite.md b/docs/porting/families/granite.md index 88e766a2..8814d968 100644 --- a/docs/porting/families/granite.md +++ b/docs/porting/families/granite.md @@ -155,7 +155,7 @@ Allowed statuses: `PASS`, `SKIP - not exposed by runtime`, `ACCEPTED GAP - ` suffix is the spec's `wer.metadata_key` (default `librispeech_test_clean`); the `` suffix is the `perf:` rig key with `-` -mapped to `_`. Standard HF keys (`license`, `language`, `pipeline_tag`, +mapped to `_`. A spec can also publish secondary benchmarks inline: any dataset-named key +under `wer:` whose value is a `{quant: wer%}` map (e.g. `librispeech_test_clean:`) +is emitted as its own `wer_` block alongside the headline one — so a +model whose per-quant column is e.g. FLEURS can still expose LibriSpeech +machine-readably. Add a dataset by adding a key; no wrapper needed. Standard HF keys (`license`, `language`, `pipeline_tag`, `base_model`, `tags`, …) are emitted alongside and unchanged by this block. ## Reading it diff --git a/scripts/hf_cards/generate.py b/scripts/hf_cards/generate.py index 47706acf..12ed07de 100755 --- a/scripts/hf_cards/generate.py +++ b/scripts/hf_cards/generate.py @@ -50,20 +50,28 @@ def build_transcribe_cpp_block(spec: dict) -> str: return "" caps = spec.get("capabilities", {}) - dataset_key = spec["wer"].get("metadata_key", "librispeech_test_clean") - block: dict = { - f"wer_{dataset_key}": { - q["name"].lower(): float(str(q["wer"]).rstrip("%")) for q in spec["quants"] - }, - **{ - f"rtf_{machine.replace('-', '_')}": backends - for machine, backends in spec["perf"].items() - }, - "streaming": bool(caps.get("streaming", False)), - "translate": bool(caps.get("translate", False)), - "lang_detect": bool(caps.get("lang_detect", False)), - "timestamps": caps.get("timestamps", "none"), + wer = spec["wer"] + dataset_key = wer.get("metadata_key", "librispeech_test_clean") + block: dict = {} + # Headline dataset: per-quant WER taken from the `quants:` column. + block[f"wer_{dataset_key}"] = { + q["name"].lower(): float(str(q["wer"]).rstrip("%")) for q in spec["quants"] } + # Any additional per-quant WER maps listed inline under `wer:` (keyed by + # dataset name, e.g. `librispeech_test_clean:`) are emitted as their own + # `wer_` blocks. Only dict values count as datasets; scalar keys + # (metadata_key, source, notes) are skipped. + for key, per_quant in wer.items(): + if isinstance(per_quant, dict): + block[f"wer_{key}"] = { + str(q).lower(): float(str(v).rstrip("%")) for q, v in per_quant.items() + } + for machine, backends in spec["perf"].items(): + block[f"rtf_{machine.replace('-', '_')}"] = backends + block["streaming"] = bool(caps.get("streaming", False)) + block["translate"] = bool(caps.get("translate", False)) + block["lang_detect"] = bool(caps.get("lang_detect", False)) + block["timestamps"] = caps.get("timestamps", "none") dumped = yaml.safe_dump( {"transcribe_cpp": block}, sort_keys=False, default_flow_style=False ) diff --git a/scripts/hf_cards/granite-4.0-1b-speech.yaml b/scripts/hf_cards/granite-4.0-1b-speech.yaml index 8fb87905..24b3ac96 100644 --- a/scripts/hf_cards/granite-4.0-1b-speech.yaml +++ b/scripts/hf_cards/granite-4.0-1b-speech.yaml @@ -50,7 +50,7 @@ capabilities: streaming: false translate: true lang_detect: false - timestamps: word # none | segment | word | token + timestamps: none # none | segment | word | token # Speedup-over-realtime (×RT) per rig/backend, from docs/models; published # raw as rtf_ in the metadata block. @@ -62,6 +62,7 @@ perf: metal: 11 cpu: 5 ryzen-4750u: + vulkan: 2.95 cpu: 1.6 wer: diff --git a/scripts/hf_cards/granite-speech-4.1-2b-nar.yaml b/scripts/hf_cards/granite-speech-4.1-2b-nar.yaml index a9f2b16c..d624036f 100644 --- a/scripts/hf_cards/granite-speech-4.1-2b-nar.yaml +++ b/scripts/hf_cards/granite-speech-4.1-2b-nar.yaml @@ -63,6 +63,7 @@ perf: metal: 18 cpu: 4 ryzen-4750u: + vulkan: 3.65 cpu: 1.5 wer: 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 82a6d757..9c8303ad 100644 --- a/scripts/hf_cards/granite-speech-4.1-2b-plus.yaml +++ b/scripts/hf_cards/granite-speech-4.1-2b-plus.yaml @@ -41,8 +41,9 @@ summary: | decoder) with two changes: the encoder concatenates mid-layer (idx 3) and final-layer hidden states (`cat_hidden_layers=[3]`, doubling the projector K/V input from 1024 to 2048), and the LM token embeddings are tied with - the lm_head. Takes a 16 kHz mono WAV and produces a transcript, optionally - interleaved with `[SS:N]` word-timestamp markers. Transcribes English, + the lm_head. Takes a 16 kHz mono WAV and produces a transcript, with + `--timestamps word` returning structured per-word timestamps (parsed from the + model's `[T:N]` centisecond markers). Transcribes English, French, German, Spanish, and Portuguese (no Japanese on this variant). This variant is transcription-only: unlike the base granite-speech-4.1-2b, it does not perform speech translation. @@ -66,6 +67,7 @@ perf: metal: 11 cpu: 5 ryzen-4750u: + vulkan: 2.75 cpu: 1.4 wer: diff --git a/scripts/hf_cards/granite-speech-4.1-2b.yaml b/scripts/hf_cards/granite-speech-4.1-2b.yaml index c01959cd..94ce2399 100644 --- a/scripts/hf_cards/granite-speech-4.1-2b.yaml +++ b/scripts/hf_cards/granite-speech-4.1-2b.yaml @@ -51,7 +51,7 @@ capabilities: streaming: false translate: true lang_detect: false - timestamps: word # none | segment | word | token + timestamps: none # none | segment | word | token # Speedup-over-realtime (×RT) per rig/backend, from docs/models; published # raw as rtf_ in the metadata block. @@ -63,6 +63,7 @@ perf: metal: 12 cpu: 4 ryzen-4750u: + vulkan: 2.80 cpu: 1.4 wer: diff --git a/scripts/hf_cards/nemotron-3.5-asr-streaming-0.6b.yaml b/scripts/hf_cards/nemotron-3.5-asr-streaming-0.6b.yaml index 2312ebf9..660bfed1 100644 --- a/scripts/hf_cards/nemotron-3.5-asr-streaming-0.6b.yaml +++ b/scripts/hf_cards/nemotron-3.5-asr-streaming-0.6b.yaml @@ -90,7 +90,14 @@ wer: metadata_key: fleurs_en source: FLEURS test en (en-US), offline att_context_size=[56, 13] notes: | - WER measured on FLEURS test en (647 utterances), greedy RNN-T, --language en-US, whisper-normalizer scoring; the per-quant column is FLEURS en. NeMo reference baseline on the same manifest: 7.99% (NVIDIA self-reports 7.91% en-US). On LibriSpeech test-clean (2620 utterances) the same presets score F32 3.04 / F16 3.04 / Q8_0 3.05 / Q6_K 3.08 / Q5_K_M 3.10 / Q4_K_M 3.30, against a 3.03% NeMo reference. + WER measured on FLEURS test en (647 utterances), greedy RNN-T, --language en-US, whisper-normalizer scoring; the per-quant column is FLEURS en. NeMo reference baseline on the same manifest: 7.99% (NVIDIA self-reports 7.91% en-US). On LibriSpeech test-clean (2620 utterances) the same presets score F32 3.04 / F16 3.03 / Q8_0 3.06 / Q6_K 3.07 / Q5_K_M 3.10 / Q4_K_M 3.28, against a 3.03% NeMo reference. + librispeech_test_clean: + f32: 3.04 + f16: 3.03 + q8_0: 3.06 + q6_k: 3.07 + q5_k_m: 3.10 + q4_k_m: 3.28 quants: - name: F32 diff --git a/src/arch/granite/capabilities.cpp b/src/arch/granite/capabilities.cpp index 3a4356b7..220b2a12 100644 --- a/src/arch/granite/capabilities.cpp +++ b/src/arch/granite/capabilities.cpp @@ -9,14 +9,9 @@ void apply_family_invariants(transcribe_model & model) { caps.native_sample_rate = 16000; - // Word timestamps are advertised on the -plus variant (the - // timestamps prompt template emits inline `<|n.nn|> word` markers). - // The 1b / 2b variants only emit transcript text. The runtime - // distinguishes via a per-variant capability KV (the converter sets - // stt.capability.word_timestamps appropriately); apply_family_invariants - // sets the default upper bound here so the loader's - // read_capability_kv step can lower it for variants that do not - // expose timestamps. + // Only -plus exposes word timestamps ("[T:N]" markers). WORD is the family + // upper bound; granite::load() lowers it to NONE per variant from the GGUF's + // stt.capability.word_timestamps. caps.max_timestamp_kind = TRANSCRIBE_TIMESTAMPS_WORD; // Translation is advertised on the 1b / 2b variants via a separate diff --git a/src/arch/granite/model.cpp b/src/arch/granite/model.cpp index ebfce90d..dc24f439 100644 --- a/src/arch/granite/model.cpp +++ b/src/arch/granite/model.cpp @@ -243,6 +243,21 @@ transcribe_status load(Loader & loader, const transcribe_model_load_params * par if (const transcribe_status st = read_capability_kv(loader.gguf(), m->caps); st != TRANSCRIBE_OK) { return st; } + + // Only -plus exposes word timestamps; lower the WORD family default to NONE + // when the GGUF does not advertise stt.capability.word_timestamps. + { + bool word_ts = false; + if (const transcribe_status st = + read_optional_bool_kv(loader.gguf(), "stt.capability.word_timestamps", "granite", false, word_ts); + st != TRANSCRIBE_OK) { + return st; + } + if (!word_ts) { + m->caps.max_timestamp_kind = TRANSCRIBE_TIMESTAMPS_NONE; + } + } + if (const transcribe_status st = read_languages_kv(loader.gguf(), *m); st != TRANSCRIBE_OK) { return st; } @@ -512,9 +527,13 @@ static transcribe_status build_granite_affixes(GraniteModel * cm, return TRANSCRIBE_ERR_INVALID_ARG; } instruction = std::string("can you translate the speech into ") + lang_name + "?"; - } else if (params->timestamps == TRANSCRIBE_TIMESTAMPS_WORD || - params->timestamps == TRANSCRIBE_TIMESTAMPS_AUTO) { - instruction = "transcribe the speech with timestamps in [SS:MS] format"; + } else if (params->timestamps == TRANSCRIBE_TIMESTAMPS_WORD) { + // -plus only (1b/2b advertise NONE, gated out upstream). AUTO does + // NOT request timestamps. IBM's verbatim prompt; the model emits + // per-word "[T:N]" centisecond markers (parsed in run()). + instruction = + " Timestamps: Transcribe the speech. After each word, add a timestamp tag " + "showing the end time in centiseconds, e.g. hello [T:45] world [T:82]"; } } @@ -583,6 +602,117 @@ static transcribe_status build_granite_affixes(GraniteModel * cm, return TRANSCRIBE_OK; } +// Parse -plus inline " [T:N]" markers (N = preceding word's end in +// centiseconds, mod 1000 / 10 s rollover) into structured words + one segment. +// "_" items are silence placeholders (advance the clock, not emitted). Returns +// the clean transcript; leaves the vectors empty on marker-free output. +static std::string parse_granite_word_timestamps(const std::string & raw, + int64_t audio_ms, + std::vector & out_words, + std::vector & out_segments) { + auto is_space = [](char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r'; + }; + auto trim = [&](const std::string & s) -> std::string { + size_t a = 0, b = s.size(); + while (a < b && is_space(s[a])) { + ++a; + } + while (b > a && is_space(s[b - 1])) { + --b; + } + return s.substr(a, b - a); + }; + + std::string pending; + int rollover = 0; // count of 10 s (1000 cs) wraps seen so far + long last_raw = -1; // previous raw centisecond value (pre-unwrap) + int64_t prev_end_ms = 0; // end of the previous marker (word or "_") + + const size_t n = raw.size(); + size_t i = 0; + while (i < n) { + // Recognize a "[T:]" marker (tolerate spaces around the digits). + if (raw[i] == '[' && i + 2 < n && raw[i + 1] == 'T' && raw[i + 2] == ':') { + size_t j = i + 3; + while (j < n && is_space(raw[j])) { + ++j; + } + long val = 0; + bool has_digit = false; + while (j < n && raw[j] >= '0' && raw[j] <= '9') { + val = val * 10 + (raw[j] - '0'); + has_digit = true; + ++j; + } + while (j < n && is_space(raw[j])) { + ++j; + } + if (has_digit && j < n && raw[j] == ']') { + ++j; // consume ']' + if (last_raw >= 0 && val < last_raw) { + ++rollover; + } + last_raw = val; + const int64_t end_ms = (static_cast(val) + static_cast(rollover) * 1000) * 10; + const std::string w = trim(pending); + if (!w.empty() && w != "_") { + transcribe_session::WordEntry we; + we.text = w; + we.t0_ms = prev_end_ms; + we.t1_ms = end_ms; + we.seg_index = 0; + we.first_token = 0; + we.n_tokens = 0; + out_words.push_back(std::move(we)); + } + prev_end_ms = end_ms; + pending.clear(); + i = j; + continue; + } + // Not a well-formed marker — fall through and treat '[' as text. + } + pending.push_back(raw[i]); + ++i; + } + + // A trailing word with no marker keeps its text but has no model end time; + // bound it by the audio duration. + if (const std::string w = trim(pending); !w.empty() && w != "_") { + transcribe_session::WordEntry we; + we.text = w; + we.t0_ms = prev_end_ms; + we.t1_ms = audio_ms > prev_end_ms ? audio_ms : prev_end_ms; + we.seg_index = 0; + we.first_token = 0; + we.n_tokens = 0; + out_words.push_back(std::move(we)); + } + + std::string clean; + for (size_t k = 0; k < out_words.size(); ++k) { + if (k != 0) { + clean.push_back(' '); + } + clean += out_words[k].text; + } + + if (!out_words.empty()) { + transcribe_session::SegmentEntry seg; + seg.text = clean; + seg.t0_ms = out_words.front().t0_ms; + seg.t1_ms = out_words.back().t1_ms; + seg.first_word = 0; + seg.n_words = static_cast(out_words.size()); + seg.first_token = 0; + seg.n_tokens = 0; + out_segments.push_back(std::move(seg)); + } + + return clean; +} + transcribe_status run(transcribe_session * ctx_base, const float * pcm, int n_samples, @@ -820,9 +950,8 @@ transcribe_status run(transcribe_session * ctx_base, // granite-speech-4.1-2b-plus : " can you transcribe the speech into a written format?" // (leading space matters: BPE token IDs differ) // PLUS a Granite-specific system prompt (see use_granite4_chat below). - // word-timestamps task : "transcribe the speech with timestamps in [SS:MS] format" - // (only -plus emits the [SS:N] markers; 1b/2b - // fall back to plain transcript) + // word-timestamps task (-plus) : IBM's verbatim timestamps prompt; the model emits + // per-word "[T:N]" centisecond markers (1b/2b: NONE). // translate task : "can you translate the speech into ?" std::vector prefix_ids; std::vector suffix_ids; @@ -1098,15 +1227,26 @@ transcribe_status run(transcribe_session * ctx_base, } // Detokenize. - cc->full_text = cm->tok.decode(gen_ids.data(), static_cast(gen_ids.size())); - - cc->result_kind = TRANSCRIBE_TIMESTAMPS_NONE; - cc->has_result = true; - transcribe_session::SegmentEntry seg{}; - seg.text = cc->full_text; - seg.t0_ms = 0; - seg.t1_ms = static_cast(n_samples) * 1000 / static_cast(cm->hparams.fe_sample_rate); - cc->segments.push_back(std::move(seg)); + const std::string raw_text = cm->tok.decode(gen_ids.data(), static_cast(gen_ids.size())); + const int64_t audio_ms = static_cast(n_samples) * 1000 / static_cast(cm->hparams.fe_sample_rate); + + cc->has_result = true; + // -plus word timestamps: parse "[T:N]" markers into words, else plain text. + if (params != nullptr && params->timestamps == TRANSCRIBE_TIMESTAMPS_WORD) { + cc->full_text = parse_granite_word_timestamps(raw_text, audio_ms, cc->words, cc->segments); + if (!cc->words.empty()) { + cc->result_kind = TRANSCRIBE_TIMESTAMPS_WORD; + } + } + if (cc->words.empty()) { + cc->full_text = raw_text; + cc->result_kind = TRANSCRIBE_TIMESTAMPS_NONE; + transcribe_session::SegmentEntry seg{}; + seg.text = cc->full_text; + seg.t0_ms = 0; + seg.t1_ms = audio_ms; + cc->segments.push_back(std::move(seg)); + } // Output truncation (decode hit the generation budget / context ceiling // before EOS) is a hard status, not a silent success: surface it so the @@ -1597,17 +1737,27 @@ transcribe_status run_batch(transcribe_session * session, if (!gen.empty() && gen.back() == eos_id) { gen.pop_back(); } - std::string transcript = cm->tok.decode(gen.data(), static_cast(gen.size())); + const std::string transcript = cm->tok.decode(gen.data(), static_cast(gen.size())); + const int64_t audio_ms = static_cast(n_samples[b]) * 1000 / static_cast(hp.fe_sample_rate); transcribe_session::ResultSet rs; - rs.full_text = transcript; - rs.result_kind = TRANSCRIBE_TIMESTAMPS_NONE; - rs.has_result = true; - rs.status = TRANSCRIBE_OK; - transcribe_session::SegmentEntry seg{}; - seg.text = transcript; - seg.t0_ms = 0; - seg.t1_ms = static_cast(n_samples[b]) * 1000 / static_cast(hp.fe_sample_rate); - rs.segments.push_back(std::move(seg)); + rs.has_result = true; + rs.status = TRANSCRIBE_OK; + // Same as run(): parse -plus "[T:N]" word markers, else plain-text segment. + if (params != nullptr && params->timestamps == TRANSCRIBE_TIMESTAMPS_WORD) { + rs.full_text = parse_granite_word_timestamps(transcript, audio_ms, rs.words, rs.segments); + if (!rs.words.empty()) { + rs.result_kind = TRANSCRIBE_TIMESTAMPS_WORD; + } + } + if (rs.words.empty()) { + rs.full_text = transcript; + rs.result_kind = TRANSCRIBE_TIMESTAMPS_NONE; + transcribe_session::SegmentEntry seg{}; + seg.text = transcript; + seg.t0_ms = 0; + seg.t1_ms = audio_ms; + rs.segments.push_back(std::move(seg)); + } // Per-utterance truncation parity with single-shot run(): a row cut at // the generation budget / KV window before eos reports // TRANSCRIBE_ERR_OUTPUT_TRUNCATED (partial transcript retained). Only diff --git a/src/transcribe-meta.cpp b/src/transcribe-meta.cpp index a5ff5aa3..9db7bb86 100644 --- a/src/transcribe-meta.cpp +++ b/src/transcribe-meta.cpp @@ -312,10 +312,9 @@ transcribe_status read_capability_kv(const gguf_context * gguf, transcribe_capab return TRANSCRIBE_ERR_INVALID_ARG; } - // Timestamp granularity (max_timestamp_kind) is deliberately NOT read - // here: every family has a fixed, code-set ceiling and no converter - // emits stt.capability.timestamps. A KV-driven override (which could - // only lower the ceiling, never raise it) is a future change. + // max_timestamp_kind is NOT read here: converters emit timestamp-capability + // KVs (canary stt.capability.timestamps, granite stt.capability.word_timestamps), + // but the ceiling is variant-specific and applied in each family's load(). if (auto st = read_capability_bool(gguf, "stt.capability.translate", caps.supports_translate); st != TRANSCRIBE_OK) { return st;