transcribe-cpp-sys 0.0.7

Native FFI bindings for transcribe.cpp, a C/C++ speech-to-text library built on ggml
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
// arch/parakeet/weights.h - canonical Parakeet tensor catalog and
// per-instance weight slots.
//
// This header is INTERNAL to src/arch/parakeet/. It defines:
//
//   - ParakeetHParams: the architecture KV the loader reads from
//     stt.parakeet.* / stt.frontend.* before allocating any tensors.
//     Every dim that drives a tensor shape lives here.
//
//   - ParakeetWeights: a struct of named borrowed ggml_tensor* slots,
//     one per logical weight in a Parakeet model. The actual storage
//     is owned by the model's ggml_context (allocated by
//     gguf_init_from_file with no_alloc=false). The slots are
//     borrowed pointers and must not outlive the ggml_context.
//
// The corresponding source file walks the canonical tensor name list
// and validates each tensor (presence + shape) against the hparams,
// following llama.cpp's `create_tensor`-with-explicit-shape pattern.
// Required by default; the few honestly-optional tensors are wrapped
// in a NOT_REQUIRED flag.
//
// Naming conventions baked in here (and matched by the converter):
//
//   - Linear weight tensors are stored in PyTorch order [out, in].
//     ggml_mul_mat(W, x) takes the activation row vector x and reads
//     ne0 of W as the input dim — so PyTorch [out, in] is what we
//     want.
//   - Conv2d weight tensors are stored in PyTorch OIHW
//     [out_channels, in_channels, kH, kW], matching NeMo's native
//     layout. Phase 4 figures out whether to feed this directly to
//     ggml_conv_2d or transpose again at runtime — that's not 2C's
//     problem.
//   - Conv1d weight tensors (the depthwise/pointwise convs inside
//     the Conformer block) are stored as PyTorch
//     [out_channels, in_channels, kernel] for the pointwise convs and
//     [out_channels, kernel, 1] for the depthwise (kernel * groups,
//     where groups=out_channels=in_channels for depthwise).
//   - LSTM gate matrices (Wx, Wh) are stored as a single concatenated
//     [4*hidden, input] tensor in PyTorch order (i, f, g, o gates
//     stacked along the row dimension). Bias is concatenated [4*hidden].
//     This matches NeMo's safetensors layout exactly so the converter
//     does not transpose.
//   - LayerNorm and RMSNorm have separate weight + bias tensors of
//     shape [d_model].
//   - BatchNorm carries running_mean and running_var alongside
//     weight + bias. We keep all four; folding into the surrounding
//     conv happens at compute time in phase 4 (encoder is in eval
//     mode at inference and BN reduces to an affine transform).

#pragma once

#include "transcribe.h"

#include <cstdint>
#include <string>
#include <utility>
#include <vector>

struct gguf_context;
struct ggml_context;
struct ggml_tensor;

namespace transcribe::parakeet {

// ---------------------------------------------------------------------------
// Hyperparameters
// ---------------------------------------------------------------------------
//
// Read from stt.parakeet.* / stt.frontend.* via read_parakeet_hparams().
// Every field that controls a tensor shape MUST live here so the
// loader can validate weight shapes deterministically against KV
// (rather than against tensor sizes the converter happened to write).

// Decoder head dispatch. Resolved from stt.parakeet.head_kind (string KV).
// Legacy v2/v3 GGUFs predate the KV; absent KV defaults to TDT, which is
// what those GGUFs are. Drives conditional KV reads in
// read_parakeet_hparams (predictor/joint/tdt only when applicable) and
// the per-head decoder dispatch in model.cpp run().
enum class HeadKind { TDT, RNNT, CTC };

struct ParakeetHParams {
    HeadKind head_kind = HeadKind::TDT;

    // Encoder (Conformer).
    int32_t enc_n_layers          = 0;
    int32_t enc_d_model           = 0;
    int32_t enc_n_heads           = 0;
    int32_t enc_d_ff              = 0; // d_model * ff_expansion_factor
    int32_t enc_conv_kernel       = 0; // depthwise conv kernel inside the Conformer block
    int32_t enc_subsampling_factor   = 0; // total downsampling on the time axis
    int32_t enc_subsampling_channels = 0; // intermediate channel count in pre_encode
    int32_t enc_pos_emb_max_len   = 0;
    bool    enc_use_bias          = false;
    // NeMo's RelPositionalEncoding multiplies x by sqrt(d_model) when
    // `xscaling=True` is set in the encoder cfg. This pre-block scaling
    // is structurally different from how v2/v3/tdt-* (xscaling=False)
    // feed the pre_encode output into block 0. Default to false so
    // legacy GGUFs without this KV (v2/v3 etc.) keep working unchanged.
    bool    enc_xscaling          = false;
    // Local attention window. -1 / -1 = full attention (the default for
    // most variants). When non-negative, each query attends only to keys
    // within an [left, right]-bounded set whose exact semantics depend
    // on enc_att_context_style below.
    //
    // The two styles in use today:
    //
    //   AttContextStyle::Regular         — every other variant. When
    //     both left/right are >= 0 (parakeet-tdt_ctc-1.1b sets
    //     [128, 128]), pos_emb is shortened to (left+right+1) and the
    //     band mask sits in matrix_bd via the existing pad/slice path,
    //     matching NeMo's LocalAttRelPositionalEncoding.
    //
    //   AttContextStyle::ChunkedLimited  — nemotron-speech-streaming-en-0.6b
    //     ([70, 13]). Full RelPositionalEncoding (pos_emb stays at
    //     2T-1); a separate chunked mask is built host-side and added
    //     to matrix_bd. chunk_size = right+1, left_chunks = left/chunk_size.
    int32_t enc_att_context_left  = -1;
    int32_t enc_att_context_right = -1;

    // Multi-lookahead training menu for cache-aware streaming models.
    // nemotron-speech-streaming-en-0.6b is trained on four (L, R) pairs
    // simultaneously (att_context_probs = [0.25, 0.25, 0.25, 0.25] over
    // [[70,13],[70,6],[70,1],[70,0]]) and the caller picks at inference
    // time via transcribe_parakeet_stream_ext::att_context_right.
    //
    // Encoded as a flat GGUF int32 array [L0,R0,L1,R1,...] under the KV
    // stt.parakeet.encoder.att_context_size_choices. Index 0 is the
    // default (max-context / max-accuracy) setting, matching NeMo's
    // att_context_size[0] convention; it always equals
    // (enc_att_context_left, enc_att_context_right). Empty for offline
    // variants — the loader synthesizes a single-element list from the
    // scalar fields above when no choices KV is present, so call sites
    // can read uniformly.
    std::vector<std::pair<int32_t, int32_t>> enc_att_context_size_choices;

    // Chunked-limited-with-rc training menu (buffered streaming variants,
    // i.e. parakeet-unified-en-0.6b). NeMo stores the 3-tuple [L, C, R]
    // training menu as three independent lists in
    // `att_chunk_context_size` — the cartesian product of the three is
    // the full set of (L, C, R) combinations the model was trained
    // against. At inference the runtime picks one entry from each list
    // to form the active (L, C, R) tuple driving the chunked attention
    // mask. Empty for variants that do not declare
    // enc_att_context_style == ChunkedLimitedWithRc.
    //
    // For parakeet-unified-en-0.6b: L ∈ {70}, C ∈ {1, 2, 7, 13},
    // R ∈ {0, 1, 2, 3, 4, 7, 13} (encoder frames at the 80ms rate).
    // The "best accuracy" default is (70, 13, 13) = 5.6s / 1.04s / 1.04s.
    std::vector<int32_t> enc_att_chunk_left_choices;
    std::vector<int32_t> enc_att_chunk_chunk_choices;
    std::vector<int32_t> enc_att_chunk_right_choices;

    // Self-attention context style (introduced by streaming variants).
    // Resolved from stt.parakeet.encoder.att_context_style (string KV);
    // optional, defaults to "regular" so every legacy GGUF stays on the
    // existing path.
    //
    //   Regular              — full attention (when L=R=-1) or local
    //     sliding window. Every offline parakeet variant ships this.
    //
    //   ChunkedLimited       — cache-aware streaming with a 2-tuple
    //     (L, R) chunk mask. nemotron-speech-streaming-en-0.6b.
    //
    //   ChunkedLimitedWithRc — buffered streaming with a 3-tuple
    //     (L, C, R) chunk mask. parakeet-unified-en-0.6b. The training
    //     menu lives in enc_att_chunk_*_choices above; the active
    //     tuple is picked at stream_begin time. Note the offline path
    //     stays on full attention even when the style is
    //     ChunkedLimitedWithRc — the cfg's `att_context_size` is
    //     [-1, -1] for this variant; the streaming mask is engaged
    //     only by the buffered driver.
    enum class AttContextStyle { Regular, ChunkedLimited, ChunkedLimitedWithRc };
    AttContextStyle enc_att_context_style = AttContextStyle::Regular;

    // Depthwise convolution context window inside the Conformer block.
    // -1 / -1 = symmetric (kernel-1)/2 on both sides (every offline
    // variant). NeMo's `conv_context_size = "causal"` emits
    // [kernel-1, 0] (nemotron-speech-streaming-en-0.6b uses kernel=9,
    // so [8, 0]) so the depthwise conv only consumes left-context.
    int32_t enc_conv_context_left  = -1;
    int32_t enc_conv_context_right = -1;

    // Conv-module normalisation choice. NeMo's offline default is
    // BatchNorm; streaming variants use LayerNorm to avoid stat
    // brittleness across chunks. With LayerNorm the GGUF carries the
    // same `conv.bn.weight` / `conv.bn.bias` tensor names but omits
    // running_mean / running_var, and the conformer applies an
    // unfused LayerNorm (compute per-channel mean/std at inference,
    // then affine-transform with bn_w / bn_b).
    enum class ConvNormType { BatchNorm, LayerNorm };
    ConvNormType enc_conv_norm_type = ConvNormType::BatchNorm;

    // Cache-aware streaming pre-encode constants. Populated from the
    // optional KVs:
    //   stt.parakeet.encoder.streaming.pre_encode_cache_size  (int32)
    //   stt.parakeet.encoder.streaming.drop_extra_pre_encoded (int32)
    // Both are properties of the ConvSubsampling stack — independent of
    // the chosen att_context_size — so they're read once and reused
    // across all latency settings.
    //
    // enc_stream_pre_encode_cache_size: number of mel-history frames the
    //   streaming encoder must prepend to each non-first chunk to fill
    //   the conv-subsample receptive field (9 on nemotron).
    // enc_stream_drop_extra_pre_encoded: number of encoder frames
    //   discarded after the subsample stack on each non-first chunk to
    //   align with the cache boundary (2 on nemotron).
    //
    // Zero when absent from the GGUF (offline variants); the streaming
    // code paths gate on both > 0.
    int32_t enc_stream_pre_encode_cache_size  = 0;
    int32_t enc_stream_drop_extra_pre_encoded = 0;
    // ConvSubsampling's "first-chunk output frames" — used to compute
    // chunk_size_first = sampling_frames_first + subsampling_factor * R.
    // FastConformer's 2-stride-2 stack ships sampling_frames=[1, 8];
    // we capture the first entry (1). Defaults to subsampling_factor
    // when absent (matches the steady-state value, i.e. legacy GGUFs
    // collapse to "no first-chunk special case").
    int32_t enc_stream_sampling_frames_first  = 0;

    // Predictor (RNN-T prediction network). TDT and RNNT only; zero for CTC.
    int32_t pred_hidden   = 0;
    int32_t pred_n_layers = 0;
    int32_t pred_vocab    = 0; // includes the +1 "start" / blank-row entry of the embed matrix
                               // (raw token vocab size is pred_vocab - 1)

    // Joint network. TDT and RNNT only; zero for CTC.
    int32_t joint_hidden            = 0;
    int32_t joint_num_extra_outputs = 0; // TDT durations count; 0 for RNNT
    // Joint output activation. NeMo Parakeet 0.6B v2/v3 ship "relu"
    // (verified against config.json on both variants); the rnnt.py
    // reference defaults to "tanh" but no published Parakeet variant
    // we know about uses tanh. Read from stt.parakeet.joint.activation
    // and validated against the small allow-list the C++ joint forward
    // implements. Without this read at load time the C++ side would
    // hard-code an activation choice and silently produce wrong logits
    // on a model that asked for a different one.
    std::string joint_activation;

    // TDT decoding parameters.
    //
    // tdt_durations: the duration values the joint network's "extra
    // outputs" axis predicts (e.g. [0,1,2,3,4] for the published 0.6B
    // variants). The argmax over duration logits selects an index into
    // this list; the chosen integer is how many encoder frames the TDT
    // decode driver advances after emitting a token. PLAN.md fixes the
    // KV name as `stt.parakeet.tdt.durations` (i32 array). Length must
    // equal joint_num_extra_outputs (we cross-validate at load time).
    std::vector<int32_t> tdt_durations;

    // tdt_max_symbols: the per-encoder-frame "stuck" cap from NeMo's
    // greedy_batch decoder. Optional KV with a documented default of 10
    // (matches both published v2 and v3 configs). When the decoder has
    // emitted N consecutive zero-duration tokens for the same frame
    // without advancing time, it forces a +1 advance to break the
    // loop. Set to 0 to disable.
    int32_t tdt_max_symbols = 10;

    // Frontend (mel feature extractor). The full set of stt.frontend.*
    // KV the converter emits and the phase 3 C++ frontend reads. PLAN.md
    // declares this list as the *complete* set the converter and loader
    // must agree on, so the loader is the gate that catches any future
    // converter that drops a field. Fields irrelevant to Parakeet
    // (LFR window/shift, CMVN mean/inv_stddev) are not in this struct
    // because no published Parakeet variant uses them — when SenseVoice
    // or another LFR/CMVN family lands, those fields move to a
    // family-agnostic FrontendParams struct.
    std::string fe_type;          // "mel" for parakeet
    int32_t     fe_num_mels    = 0;
    int32_t     fe_sample_rate = 0;
    int32_t     fe_n_fft       = 0;
    int32_t     fe_win_length  = 0;  // samples
    int32_t     fe_hop_length  = 0;  // samples
    std::string fe_window;        // "hann" for parakeet
    std::string fe_normalize;     // "per_feature" for parakeet
    float       fe_dither       = 0.0f;
    float       fe_pre_emphasis = 0.0f;  // 0.0 == disabled
    float       fe_f_min        = 0.0f;
    float       fe_f_max        = 0.0f;

    // CTC head. Populated by build_parakeet_weights when head_kind=CTC,
    // by reading the leading dim of head.ctc.weight (shape
    // [vocab+1, d_model, 1] in PyTorch -> ggml ne [1, d_model, vocab+1]).
    // Zero for TDT/RNNT.
    int32_t head_ctc_n_classes = 0;

    // Language-conditioning prompt MLP (NeMo's
    // EncDecRNNTBPEModelWithPrompt). Multilingual variants prepend a
    // 2-layer MLP that mixes a one-hot prompt vector into the encoder
    // output before the RNN-T joint sees it:
    //
    //     x = concat(enc[d_model], one_hot(prompt_id)[num_prompts])
    //     h = relu(W0 @ x + b0)        // W0: [prompt_hidden, d_model+P]
    //     y = W2 @ h + b2              // W2: [d_model, prompt_hidden]
    //     enc_out := y
    //
    // Gated on `has_prompt` (true iff stt.parakeet.prompt.num_prompts
    // is present in the GGUF). Today: nemotron-3.5-asr-streaming-0.6b.
    // Loader reads stt.parakeet.prompt.{num_prompts, hidden, field,
    // activation, dictionary.locales, dictionary.indices, auto_id}.
    bool                       has_prompt              = false;
    int32_t                    prompt_num_prompts      = 0;
    int32_t                    prompt_hidden           = 0;
    std::string                prompt_field;
    std::string                prompt_activation;
    std::vector<std::string>   prompt_dictionary_locales;
    std::vector<int32_t>       prompt_dictionary_indices;
    int32_t                    prompt_auto_id          = -1;

    // Derived. Convenience accessors keyed off the above.
    int32_t enc_head_dim() const { return enc_n_heads > 0 ? enc_d_model / enc_n_heads : 0; }
    int32_t joint_n_classes() const { return (pred_vocab - 1) + joint_num_extra_outputs + 1; }
};

// Read every required stt.parakeet.* / stt.frontend.* KV into hp.
// Returns:
//   TRANSCRIBE_OK              on success.
//   TRANSCRIBE_ERR_INVALID_ARG if gguf is null.
//   TRANSCRIBE_ERR_GGUF        if a required key is missing or has the
//                              wrong type, or if cross-field invariants
//                              don't hold (e.g. d_model not divisible
//                              by n_heads).
transcribe_status read_parakeet_hparams(const gguf_context * gguf,
                                        ParakeetHParams &    hp);

// ---------------------------------------------------------------------------
// Weight slots
// ---------------------------------------------------------------------------
//
// Every ggml_tensor pointer below is a borrowed pointer into the
// model's ggml_context (the one that owns the data buffer). They are
// invalidated when the model is freed.

struct ParakeetPreEncode {
    // dw_striding subsampling: 1 standard conv + 2 depthwise-separable
    // conv pairs = 8x downsampling on the time axis. The numeric
    // indices in the names below come from NeMo's torch.nn.Sequential
    // layout (1 and 4 are activation modules, hence skipped).
    ggml_tensor * conv0_w = nullptr; // [out=channels,        in=1,        kh=3, kw=3]
    ggml_tensor * conv0_b = nullptr; // [channels]
    ggml_tensor * conv2_w = nullptr; // [out=channels,        in=1 (dw),   kh=3, kw=3]
    ggml_tensor * conv2_b = nullptr; // [channels]
    ggml_tensor * conv3_w = nullptr; // [out=channels,        in=channels, kh=1, kw=1]
    ggml_tensor * conv3_b = nullptr; // [channels]
    ggml_tensor * conv5_w = nullptr; // [out=channels,        in=1 (dw),   kh=3, kw=3]
    ggml_tensor * conv5_b = nullptr; // [channels]
    ggml_tensor * conv6_w = nullptr; // [out=channels,        in=channels, kh=1, kw=1]
    ggml_tensor * conv6_b = nullptr; // [channels]
    // Final projection from flattened [channels * (num_mels / subsampling)] to d_model.
    ggml_tensor * out_w   = nullptr;
    ggml_tensor * out_b   = nullptr;
};

// One Conformer block. Same shape contract for every block; the loader
// builds n_layers of these. The bias slots (`*_b` on the linear/conv
// layers, NOT on the layer norms / BN / pos_bias) are populated only
// when `enc_use_bias=true` — left null for the v2/v3 baseline. The
// shared `transcribe::conformer::BlockView` already accepts nullable
// biases on every linear/conv slot.
struct ParakeetBlock {
    // Macaron feed-forward 1.
    ggml_tensor * norm_ff1_w = nullptr; // [d_model]
    ggml_tensor * norm_ff1_b = nullptr; // [d_model]
    ggml_tensor * ff1_lin1_w = nullptr; // [d_ff,    d_model]
    ggml_tensor * ff1_lin1_b = nullptr; // [d_ff],    nullable (use_bias)
    ggml_tensor * ff1_lin2_w = nullptr; // [d_model, d_ff]
    ggml_tensor * ff1_lin2_b = nullptr; // [d_model], nullable (use_bias)

    // Self-attention with relative positional encoding.
    ggml_tensor * norm_attn_w  = nullptr; // [d_model]
    ggml_tensor * norm_attn_b  = nullptr; // [d_model]
    ggml_tensor * attn_q_w     = nullptr; // [d_model, d_model]
    ggml_tensor * attn_q_b     = nullptr; // [d_model], nullable (use_bias)
    ggml_tensor * attn_k_w     = nullptr; // [d_model, d_model]
    ggml_tensor * attn_k_b     = nullptr; // [d_model], nullable (use_bias)
    ggml_tensor * attn_v_w     = nullptr; // [d_model, d_model]
    ggml_tensor * attn_v_b     = nullptr; // [d_model], nullable (use_bias)
    ggml_tensor * attn_out_w   = nullptr; // [d_model, d_model]
    ggml_tensor * attn_out_b   = nullptr; // [d_model], nullable (use_bias)
    ggml_tensor * attn_pos_w   = nullptr; // [d_model, d_model]   (linear_pos is bias-free in NeMo even when use_bias=true)
    ggml_tensor * attn_pos_u   = nullptr; // [n_heads, head_dim]   learned rel-pos bias u
    ggml_tensor * attn_pos_v   = nullptr; // [n_heads, head_dim]   learned rel-pos bias v

    // Convolution module: pointwise -> GLU -> depthwise -> BN -> activation -> pointwise.
    ggml_tensor * norm_conv_w  = nullptr; // [d_model]
    ggml_tensor * norm_conv_b  = nullptr; // [d_model]
    ggml_tensor * conv_pw1_w   = nullptr; // [2*d_model, d_model, 1]   conv1d k=1; 2x for GLU
    ggml_tensor * conv_pw1_b   = nullptr; // [2*d_model], nullable (use_bias)
    ggml_tensor * conv_dw_w    = nullptr; // [d_model,   1,       k]   depthwise conv1d
    ggml_tensor * conv_dw_b    = nullptr; // [d_model],   nullable (use_bias)
    ggml_tensor * conv_pw2_w   = nullptr; // [d_model,   d_model, 1]   conv1d k=1
    ggml_tensor * conv_pw2_b   = nullptr; // [d_model],   nullable (use_bias)
    ggml_tensor * conv_bn_w    = nullptr; // [d_model]   BN scale
    ggml_tensor * conv_bn_b    = nullptr; // [d_model]   BN shift
    ggml_tensor * conv_bn_rm   = nullptr; // [d_model]   running_mean
    ggml_tensor * conv_bn_rv   = nullptr; // [d_model]   running_var

    // Fused BN parameters (computed at load time, replaces batch_norm()
    // in the graph with a single mul + add):
    //   fused_scale = bn_w / sqrt(bn_rv + eps)
    //   fused_bias  = bn_b - bn_rm * fused_scale
    ggml_tensor * conv_bn_fused_scale = nullptr; // [d_model]
    ggml_tensor * conv_bn_fused_bias  = nullptr; // [d_model]

    // Macaron feed-forward 2.
    ggml_tensor * norm_ff2_w = nullptr; // [d_model]
    ggml_tensor * norm_ff2_b = nullptr; // [d_model]
    ggml_tensor * ff2_lin1_w = nullptr; // [d_ff,    d_model]
    ggml_tensor * ff2_lin1_b = nullptr; // [d_ff],    nullable (use_bias)
    ggml_tensor * ff2_lin2_w = nullptr; // [d_model, d_ff]
    ggml_tensor * ff2_lin2_b = nullptr; // [d_model], nullable (use_bias)

    // Final per-block layer norm.
    ggml_tensor * norm_out_w = nullptr; // [d_model]
    ggml_tensor * norm_out_b = nullptr; // [d_model]
};

struct ParakeetPredictor {
    // Token embedding. The +1 row is the "start of sequence / no
    // previous token" embedding NeMo prepends to vocab_size.
    ggml_tensor * embed_w = nullptr; // [pred_vocab, pred_hidden]

    // 2-layer LSTM by default for v2/v3 0.6b. Concatenated gate
    // weights in PyTorch (i, f, g, o) order, 4*pred_hidden rows.
    struct LstmLayer {
        ggml_tensor * Wx = nullptr; // [4*pred_hidden, pred_hidden]   (input-to-hidden, layer 0 input dim is pred_hidden because of the embed)
        ggml_tensor * Wh = nullptr; // [4*pred_hidden, pred_hidden]   (hidden-to-hidden)
        ggml_tensor * b  = nullptr; // [4*pred_hidden]
    };
    std::vector<LstmLayer> lstm; // pred_n_layers entries
};

struct ParakeetJoint {
    ggml_tensor * enc_w  = nullptr; // [joint_hidden, enc_d_model]
    ggml_tensor * enc_b  = nullptr; // [joint_hidden]
    ggml_tensor * pred_w = nullptr; // [joint_hidden, pred_hidden]
    ggml_tensor * pred_b = nullptr; // [joint_hidden]
    ggml_tensor * out_w  = nullptr; // [joint_n_classes, joint_hidden]
    ggml_tensor * out_b  = nullptr; // [joint_n_classes]
};

// CTC head. Single 1×1 Conv1d projecting d_model -> vocab+1. Loaded
// only when head_kind=CTC; predictor + joint stay empty in that case.
struct ParakeetCtcHead {
    ggml_tensor * weight = nullptr; // [vocab+1, d_model, 1]   conv1d k=1
    ggml_tensor * bias   = nullptr; // [vocab+1]
};

// Language-conditioning prompt MLP (multilingual variants). NeMo's
// nn.Sequential indexing is preserved: `.0` is the input linear,
// `.2` is the output linear, `.1` is the parameter-free activation.
// Loaded only when hp.has_prompt is true; otherwise the slots stay
// null and the encoder skips the prompt path.
struct ParakeetPromptMlp {
    ggml_tensor * mlp0_w = nullptr; // [prompt_hidden, d_model + num_prompts]
    ggml_tensor * mlp0_b = nullptr; // [prompt_hidden]
    ggml_tensor * mlp2_w = nullptr; // [d_model, prompt_hidden]
    ggml_tensor * mlp2_b = nullptr; // [d_model]
};

struct ParakeetWeights {
    ParakeetPreEncode          pre_encode;
    std::vector<ParakeetBlock> blocks; // hp.enc_n_layers entries
    ParakeetPredictor          predictor; // empty when head_kind=CTC
    ParakeetJoint              joint;     // empty when head_kind=CTC
    ParakeetCtcHead            ctc_head;  // populated when head_kind=CTC
    ParakeetPromptMlp          prompt;    // populated when hp.has_prompt
};

// Walk the canonical tensor list, look up each tensor by name in the
// gguf, validate its shape against hp, and store the borrowed pointer
// in the matching slot of weights. The ggml_tensor objects must
// already exist in ctx_meta — typically because the caller invoked
// gguf_init_from_file with no_alloc=false and ctx=&ctx_meta, which
// both creates ggml_tensors AND reads the data section.
//
// Follows llama.cpp's load_tensors pattern: every required tensor
// must be present with the exact expected shape; missing or
// shape-mismatched tensors return TRANSCRIBE_ERR_GGUF with a log
// message naming the offending tensor.
//
// On failure the partially-built `weights` is left in an
// indeterminate state — the caller is expected to throw the whole
// model away.
transcribe_status build_parakeet_weights(ggml_context *          ctx_meta,
                                         const ParakeetHParams & hp,
                                         ParakeetWeights &       weights);

} // namespace transcribe::parakeet