Adding a new ASR model to CrispASR is a focused exercise in five
files. The worked examples to copy from are the existing
crispasr_backend_*.cpp adapters.
‼️ clang-formatMUST be v18 — never use v22 (Homebrew's defaultclang-formatkeg, or Xcode's bundled clang-format, both currently ship v22). CI pinsclang-format-18(.github/workflows/lint.yml); v22 silently re-wraps lines and fails the lint job with ~80+ violations across the project's C++ files. Anyone formatting with v22 will produce a commit that breaks CI even though the code "looks formatted" locally.Use
./tools/format.sh— it locates clang-format-18 via Homebrew'sllvm@18keg or apt'sclang-format-18and refuses to run any other version../tools/format.shchecks;./tools/format.sh --fixrewrites in place. The script's scope mirrorslint.ymlexactly so local check ≡ CI check.Install clang-format-18:
- macOS:
brew install llvm@18(binary lands at/opt/homebrew/opt/llvm@18/bin/clang-format)- Ubuntu / Debian:
sudo apt install clang-format-18- Cross-platform via pip:
pip install 'clang-format==18.1.8'(putsclang-formatv18 onPATH; works as long as your pip user-bin precedes Homebrew's bin)Do NOT put
/opt/homebrew/bin/clang-format(currently v22) onPATHfor this project; it will silently mis-format. The wrapper script defends against this by hardcoded version-check.
Following the established convention:
struct yourmodel_context * yourmodel_init_from_file(const char * path, yourmodel_context_params p);
void yourmodel_free(struct yourmodel_context *);
char * yourmodel_transcribe(struct yourmodel_context *, const float * samples, int n);Use src/core/mel, src/core/ffn, src/core/attention, and
src/core/gguf_loader wherever they fit — they cover ~80 % of the
boilerplate.
Add bench instrumentation. Every runtime gets per-stage RAII timing
gated by YOURMODEL_BENCH=1:
static bool yourmodel_bench_enabled() {
static int v = -1;
if (v < 0) { const char* e = std::getenv("YOURMODEL_BENCH");
v = (e && *e && *e != '0') ? 1 : 0; }
return v != 0;
}
struct yourmodel_bench_stage {
const char* name;
std::chrono::steady_clock::time_point t0;
explicit yourmodel_bench_stage(const char* n)
: name(n), t0(std::chrono::steady_clock::now()) {}
~yourmodel_bench_stage() {
if (!yourmodel_bench_enabled()) return;
double ms = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - t0).count();
std::fprintf(stderr, " yourmodel_bench: %-22s %.2f ms\n", name, ms);
}
};Place yourmodel_bench_stage _b("stage_name"); at each pipeline stage
(mel, encoder, decoder, vocoder, etc.). Zero overhead when the env var
is unset (~1 ns cached getenv).
Create examples/cli/crispasr_backend_yourmodel.cpp:
#include "crispasr_backend.h"
#include "whisper_params.h"
#include "yourmodel.h"
namespace {
class YourmodelBackend : public CrispasrBackend {
public:
const char * name() const override { return "yourmodel"; }
uint32_t capabilities() const override {
return CAP_TIMESTAMPS_CTC | CAP_AUTO_DOWNLOAD | /* ... */;
}
bool init(const whisper_params & p) override { /* yourmodel_init_from_file(...) */ }
std::vector<crispasr_segment> transcribe(
const float * samples, int n, int64_t t_off,
const whisper_params & p) override { /* call yourmodel_transcribe and return segments */ }
void shutdown() override { /* yourmodel_free(...) */ }
private:
yourmodel_context * ctx_ = nullptr;
};
} // namespace
std::unique_ptr<CrispasrBackend> crispasr_make_yourmodel_backend() {
return std::make_unique<YourmodelBackend>();
}Forward the generation cap — do NOT hardcode the decode limit. An
autoregressive/LLM ASR backend has a decode loop bounded by some max_new. If
you hardcode it (const int max_new = 512;), a user transcribing long audio in
a single pass (--chunk-seconds 0) is silently truncated and --max-new-tokens
does nothing — this was #292, found in 10 backends at once. Instead:
- put the cap on the context as a field defaulting to the backend's sensible
value (
int max_new_tokens = 512;— keep whatever the old constant was, so the default does not regress), and read it for BOTH the decode loop bound and the KV-context sizing; - add a
yourmodel_set_max_new_tokens(ctx, n)setter (mirrorset_beam_size), declared in the header; - in the adapter's
transcribe(), forward it only when the user set it explicitly:yourmodel_set_max_new_tokens(ctx_, p.max_new_tokens_explicit ? p.max_new_tokens : 0);Themax_new_tokens_explicitflag exists precisely so the CLI's global 512 default never SHRINKS a backend whose own default is higher (diarize is 1024). Pass<= 0to keep the backend default. - forward it on the session path too (
src/crispasr_c_api.cpp, next to where the backend's other setters are called):yourmodel_set_max_new_tokens(s->yourmodel_ctx, s->max_new_tokens);— the session default is 0, so no explicit flag is needed there.
The same rule applies to any other decode knob (temperature, beam) — the audit for #292 confirmed those were already forwarded; max_new_tokens was the one gap.
In examples/cli/crispasr_backend.cpp:
std::unique_ptr<CrispasrBackend> crispasr_make_yourmodel_backend();
// ...
if (name == "yourmodel") return crispasr_make_yourmodel_backend();
// ...
std::vector<std::string> crispasr_list_backends() {
return { ..., "yourmodel" };
}Add the architecture string to crispasr_detect_backend_from_gguf()
so general.architecture auto-detection works.
In examples/cli/CMakeLists.txt:
add_executable(${TARGET}
# ...
crispasr_backend_yourmodel.cpp
)
target_link_libraries(${TARGET} PRIVATE
# ...
yourmodel_lib
)If your model has a canonical Q4_K HuggingFace release, add it to
src/crispasr_model_registry.cpp so -m auto / -m <name> --auto-download
works (one k_registry[] row: {"name", "file.gguf", "https://…", "~size", nullptr, nullptr}).
For TTS backends that need a codec companion (e.g. SNAC, DAC), add the companion file + URL in the same registry row:
{"yourmodel", "yourmodel.gguf", "https://…/yourmodel.gguf", "~2 GB",
"snac-24khz.gguf", // companion_file
"https://…/snac-24khz.gguf", // companion_url
"~80 MB"}, // companion_sizeTTS backends need extra wiring beyond ASR:
-
CAP_TTScapability flag: set incapabilities()so the CLI TTS path (--tts "text") routes through your backend'ssynthesize()method. Without this, the CLI says "does not support TTS". -
synthesize()override: return astd::vector<float>of mono PCM at the backend's native sample rate (24 kHz for SNAC/Mimi-based backends, 44.1 kHz for DAC/Zonos). Default returns empty. -
tts_sample_rate()override: return the output sample rate if it's not 24000 (default). The CLI uses this for WAV header + any downstream resampling. -
Codec companion loading: if your backend needs a separate codec GGUF (SNAC, DAC, Mimi), handle
params.tts_codec_modelininit():#include "crispasr_model_mgr_cli.h" #include "crispasr_model_registry.h" // ... in init(): std::string codec_path = p.tts_codec_model; if (!codec_path.empty() && codec_path != "auto") codec_path = crispasr_resolve_model_cli(codec_path, ...); if (codec_path.empty()) codec_path = discover_snac(p.model); // look next to model if (codec_path.empty()) { CrispasrRegistryEntry entry; if (crispasr_registry_lookup(p.backend, entry, ...) && ...) codec_path = crispasr_resolve_model_cli(entry.companion_filename, ...); }
See
crispasr_backend_orpheus.cpporcrispasr_backend_mini_omni2.cppfor worked examples.
§2–§5 make --backend X work on the CLI. To reach every other consumer
(Python, Go, Dart, server) and -m <file> auto-detection, wire the points
below. chatterbox is the canonical template — grep -n chatterbox
(or CA_HAVE_CHATTERBOX) in each file shows the exact pattern to copy.
So -m model.gguf without --backend routes correctly, add the name to
both passes of crispasr_detect_backend_from_gguf():
- Pass 1 (filename heuristic):
if (contains_ci("yourmodel")) return "yourmodel"; - Pass 2 (GGUF
general.architecture):else if (a == "<arch>") result = "yourmodel";—<arch>is whatever your converter passes toGGUFWriter(arch=...).
The --list-backends capability row is read live from the backend's
capabilities(), so it needs no separate edit.
Python/Go/Dart/server use the session C ABI, not the CLI factory.
Nine edit points, each mirroring the CA_HAVE_CHATTERBOX blocks:
- include + flag:
#if __has_include("yourmodel.h")→#include→#define CA_HAVE_YOURMODEL 1→#endif. - session struct field:
#ifdef CA_HAVE_YOURMODEL yourmodel_context* yourmodel_ctx = nullptr; #endif. crispasr_session_open_explicit()dispatch: whens->backend == "yourmodel", build params +yourmodel_init_from_file(model_path, p).crispasr_session_synthesize()(TTS) or transcribe dispatch:if (s->yourmodel_ctx) return yourmodel_synthesize(s->yourmodel_ctx, text, out_n_samples);. 4b.crispasr_session_speech_to_speech()(S2S) — if the backend hasCAP_S2S. Dispatch toyourmodel_speech_to_speech(ctx, in, n, lang, &text, &n_out). The server exposes this viaPOST /v1/audio/speech-to-speech.crispasr_session_free(): free the ctx.crispasr_session_set_temperature()— if sampling-capable.crispasr_session_set_tts_seed()— if seedable.crispasr_session_available_backends(): append,yourmodel. The Python binding rejects any backend missing from this list, so this is not optional.set_askwiring — if the backend is an instruct-tuned audio-LLM (has a user/system prompt template): add ayourmodel_set_ask(ctx, prompt)setter to the runtime, and forwards->askin the transcribe dispatch. This letsset_ask()override the default transcription instruction. Currently wired for: granite, voxtral, qwen3-asr, glm-asr, gemma4-e2b, mimo-asr, higgs-stt. 9b.params.languagewiring — if the backend is an audio-LLM, also inject a language hint into the prompt whenparams.languageis set and non-auto. Pattern:else if (!params.language.empty() && params.language != "auto") { sys_instruction = "Transcribe the speech in " + lang_name(params.language) + "."; }. For English-only models (moonshine-streaming, kyutai-stt), emit afprintf(stderr, ...)warning instead of silently ignoring the flag. Currently wired for: granite (v3 + v4 templates), qwen3-asr, glm-asr, moss-audio, mimo-asr, higgs-stt; warned for moonshine-streaming, kyutai-stt.crispasr_session_set_speaker_id()— if the backend is a multi-speaker TTS model with integer-indexed speakers (e.g. melotts, piper, fastpitch). Add a dispatch block that bounds-checks againstyourmodel_num_speakers()and callsyourmodel_set_speaker_id(). Also wirecrispasr_session_n_speakers()to return the count. For name-based speaker selection (orpheus-style), wirecrispasr_session_set_speaker_name()instead.
§4 links the backend into the CLI binary. The C ABI needs it in
libcrispasr too. In the "C-ABI wrappers" block (grep
target_link_libraries(crispasr-lib PUBLIC):
if (TARGET yourmodel_lib)
target_link_libraries(crispasr-lib PUBLIC yourmodel_lib)
endif()python/crispasr/_binding.py— add the name to the TTS-backend lists in thesynthesizecomment + docstring.bindings/go/crispasr_session.go— add to the header/type comments. Go links-lcrispasr(not per-backend), so no LDFLAGS change.flutter/crispasr/lib/src/crispasr.dart— add to the synthesize docstring. Dart usesDynamicLibrary.lookupFunctionwith symbol-presence checks, so new C-ABI functions are discovered automatically.
This applies to any explicit session entry point — the crispasr_session_set_*
setters and the data-returning session methods such as
crispasr_session_synthesize, crispasr_session_synthesize_raw, and
crispasr_session_speech_to_speech. None of these are auto-discovered (only a new
backend is — see the section above); every wrapper exposes them explicitly and
they are kept at full parity. When you add one, add it to all seven wrappers
(plus the WASM/JS binding and the server, listed below — mirror the nearest existing
setter/method in each: argtypes/restype, error-on-rc≠0,
and for PCM-returning methods copy synthesize: return the malloc'd float* as an
owned buffer then free via crispasr_pcm_free; free any out_text via
crispasr_session_translate_text_free):
python/crispasr/_binding.py— ctypes method onSession.bindings/go/crispasr_session.go— the cgo-preambleint crispasr_session_set_X(...)declaration and theSet Xmethod.- Rust (repo root, not
bindings/):crispasr-sys/src/lib.rsextern decl andcrispasr/src/lib.rssafepub fn. flutter/crispasr/lib/src/crispasr.dart—lookupFunction+ method.bindings/java/.../CrispasrSession.java— JNALibinterface decl + method.bindings/ruby/ext/ruby_crispasr_session.c—externdecl,rb_session_set_X, and arb_define_singleton_methodregistration.bindings/csharp/CrispASR/NativeMethods.cs—[DllImport]P/Invoke, and the public method onSessioninSession.cs. Callback delegates need[UnmanagedFunctionPointer(CallingConvention.Cdecl)]and a static field to survive GC;const char*params are[MarshalAs(UnmanagedType.LPUTF8Str)].- The HTTP server (
examples/cli/crispasr_server.cpp) exposes the equivalent as a per-requestform_*field on the transcription endpoints (or a startup flag for resident post-processors). bindings/javascript/emscripten.cpp— WASM/JS (built with emcc). The canonical surface isinclude/crispasr_session.h;docs/bindings.mdhas the per-wrapper setter table.
C# is CI-tested (.github/workflows/bindings-csharp.yml) — it compiles the
binding against the ABI and runs CrispASR.Tests. Do not let it drift; it was
unbuilt for a long time and shipped a units bug (#291) precisely because nothing
compiled the wrapper against the header.
README.md— model-table row (TTS or ASR section).docs/tts.md— backend table row (TTS backends).docs/architecture.md— a### yourmodelsection under "Per-backend architecture details"; README/tts.md linkdocs/architecture.md#yourmodel.
crispasr→ the library (libcrispasr /.dylib).crispasr-cli→ the CLI binary (OUTPUT_NAME crispasr).crispasr-diff→ the diff harness.
After C-ABI edits, build crispasr (the dylib) and re-test the Python
Session — building only crispasr-cli may leave the dylib stale, and the
binding then loads an old backend list. Verify with:
import crispasr
assert "yourmodel" in crispasr.Session.available_backends()
crispasr.Session("model.gguf", backend="yourmodel") # opens?Audit the wiring automatically. After adding a backend, run the audit — it
checks every canonical backend (those with a dedicated CLI adapter) against the
required surface (factory / c_api dispatch + available_backends list /
feature-matrix / cli.md beam list) and reports advisory coverage gaps (test,
reference dumper, README, registry, streaming). Aliases and standalone reference
dumpers are handled, not false-flagged:
python tools/check-backend-wiring.py --crispasr ./build/bin/crispasr # exit 1 on a required gap
⚠️ Commit from a separategit worktree, orgit pull --rebaseimmediately before committing. Agit add -A/commit -afrom a parallel session over a stale tree silently reverts others' work through the shared.git/index— this clobbered the entire §135 CSM landing (commit100b9ee5).git config pull.rebase trueis set in this repo.
Everything above assumes the backend produces TEXT and therefore fits
transcribe(). Some do not: source separation returns stems, pitch returns F0
frames, chord recognition returns a chord timeline. None of those can be
expressed as crispasr_segments, so they get their OWN task surface instead of
being forced through the transcribe contract. Precedents:
htdemucs/mel-band-roformer (--separate), crepe (--pitch), btc-chords
(--chords).
The trap: because such a backend never appears in a transcribe path, it is easy
to ship it working end-to-end while it remains invisible to the CLI's backend
registry. btc-chords did exactly that — runtime, --chords dispatcher,
session C ABI and wasm bindings all shipped and verified, while btc appeared
NOWHERE in examples/cli/crispasr_backend.cpp. --list-backends did not know
it existed, and docs/feature-matrix.md is generated from
--list-backends-json, so any hand-written row for it would have been silently
dropped on the next regeneration.
Wire ALL of the following:
- Task dispatcher —
examples/cli/crispasr_<task>_cli.{h,cpp}, called fromcrispasr_run_backend()and from an early route incli.cpp, before any transcribe backend is constructed. Both: a dispatch incrispasr_run.cppalone still falls through to whisper and dies on "invalid model data". - Capability bit — a new
CAP_<TASK>inexamples/cli/crispasr_backend.h, added to BOTH capability-name tables incrispasr_backend.cpp(the text one and the JSON one) so it shows up in--list-backendsand the matrix. - Redirect shim —
examples/cli/crispasr_backend_<name>.cppimplementingCrispasrBackendwhoseinit()prints "run it with--<task>" and returns false, withcapabilities()returning the task bit. This is what puts the backend in--list-backendsand gives--backend X(without the task flag) a clear error instead of a confusing one. Copycrispasr_backend_crepe.cpporcrispasr_backend_btc.cpp. - Factory + roster — the
if (name == ...)alias line and the roster list entry incrispasr_backend.cpp, plus the shim's forward declaration, plus the file inexamples/cli/CMakeLists.txt. - Both detect passes — filename heuristic and GGUF
general.architecture, incrispasr_backend.cppAND incrispasr_detect_backend_from_gguf()insrc/crispasr_c_api.cpp. These are two different functions in two different files; the c_api one is what every binding uses, and its own source comment records that crepe and htdemucs were once wired in the CLI but not there, so every binding got a null session. - Session C ABI — task-specific entry points, NOT
transcribe(). Followcrispasr_session_pitch*/crispasr_session_chords*: aruncall returning a count, ann_*accessor, a FLAT float view for the bulk read, and any string lookup separately. Flat views must be all-float even when a field is logically an integer — a mixed int/float struct read through a float view misreads the int lanes. - Language-wrapper binding — UNLIKE a plain transcribe/synthesize backend
(which the wrappers pick up automatically via the generic dispatch), a task
surface adds NEW functions that each wrapper must bind explicitly, or the
backend is C-only. This is where C# sat neglected:
tab/beats/chords/piano/pitch/separate/convertwere in the C ABI but bound in no managed wrapper. When you add a task surface, bind its run call + getters in every wrapper that exposes typed methods —bindings/csharp(SessionMusic.csis the precedent: one P/Invoke per native function, areadonly structresult type, oneMarshal.Copyof the flat view), plus python/go/flutter/etc. as applicable. Normalise time to seconds in the wrapper even when the flat view is milliseconds (chords/piano/pitch are ms; beats is already seconds) — an inconsistent unit across methods is the #291 bug. - Regenerate the matrix —
python tools/gen-feature-matrix.py. Do not hand-editdocs/feature-matrix.md; it is generated and says so at the top.
Then run the audit, which now checks the reverse direction too (advertised by the C ABI but unreachable from the CLI):
python tools/check-backend-wiring.py --crispasr ./build/bin/crispasrUnit tests (1063 of them) need no models and pass unconditionally. The ~25 integration ("live") tests need real GGUF models on disk and are env-var-gated — they SKIP cleanly when the env vars are unset.
Quick start:
# Point at your local model cache (auto-download also probes this):
export CRISPASR_MODELS_DIR=/mnt/storage/gguf-models
# Source all env vars at once:
source tests/env-live-tests.sh
# Run only the previously-failed tests:
ctest --test-dir build --rerun-failed --output-on-failure --timeout 300tests/env-live-tests.sh sets every env var the live tests expect.
Override CRISPASR_MODELS_DIR to point at your local model directory;
all other vars derive from it unless individually overridden.
Key env vars (see env-live-tests.sh for the full list):
| Variable | Used by |
|---|---|
CRISPASR_MODELS_DIR |
Well-known search dir for all model lookups |
CRISPASR_MODEL_WHISPER |
Beam search + VAD tests |
PARAFORMER_MODEL |
Paraformer live tests |
CRISPASR_TEST_DIARIZE_MODEL |
Diarization live tests |
CRISPASR_MODEL_ALIGNER |
CTC aligner live tests (canary-ctc-aligner) |
CRISPASR_CHAT_TEST_MODEL |
Chat (LLM) smoke test |
Tests that use SKIP() return exit code 4 (Catch2 convention). The
CMakeLists.txt sets SKIP_RETURN_CODE 4 so ctest reports them as
"Skipped" rather than "Failed".
CrispASR auto-enables FireRedPunc for any backend that advertises neither
CAP_PUNCTUATION_NATIVE nor CAP_PUNCTUATION_TOGGLE
(crispasr_punctuation_policy.h). Run that pass over text a model already
punctuated and you get your country.. — and, before the fix below,
ANd so as well. Every LLM-decoder ASR backend emits punctuated, sentence-cased
text, so it must declare CAP_PUNCTUATION_NATIVE; CTC and other
unpunctuated backends must NOT (they need the pass).
Do not decide this by reading the model card, and do not use
--no-punctuation to check. That flag strips punctuation after the fact
(crispasr_run.cpp), so a model that punctuates itself looks unpunctuated
under it — which is exactly how a whole audit can reach the wrong conclusion.
Print the real thing instead:
FIREREDPUNC_DEBUG=1 crispasr -m <model> -f samples/jfk.wav --backend <name> 2>&1 | grep PUNCDBG
# [PUNCDBG] in=<And so, my fellow Americans, … your country.> ← the model's OWN output
# [PUNCDBG] out=<And so, my fellow Americans, … your country..> ← the pass double-punctuatingIf in= already carries commas/stops and sentence case, add the cap. The
2026-07-27 audit found moonshine-streaming and mimo-asr needed it, while
canary-qwen (cased but unpunctuated) and firered-asr (ALL CAPS,
unpunctuated) correctly rely on the pass — so this is per-backend evidence, not
a blanket flag.
crisp_punc/, crisp_lid/ and crisp_truecase/ each exist twice: the sibling
directory (preferred, and what normally links) and a fallback copy under src/
that src/CMakeLists.txt builds only when the sibling directory is missing from
a checkout. A fix applied to one copy silently does nothing in the normal build.
This is not hypothetical: #308's capitalisation fix landed in
src/fireredpunc.cpp while crisp_punc/src/fireredpunc.cpp — the copy that
actually links — kept the bug for months. Every symptom pointed at the file that
was already correct, and instrumenting that file produced no output at all,
which is the tell. tests/test-punc-copies-in-sync.cpp now fails when the two
diverge; keep it green rather than deleting the assertion.
- FFT size must match upstream exactly. Whisper uses
torch.stftwithn_fft=400— a 400-point DFT, NOT zero-padded to 512. Zero-padding changes frequency bin spacing (k*sr/400vsk*sr/512) and corrupts the mel projection. Usecore_melwith a matching FFT callback (a naive O(N*N_freqs) DFT is fast enough for N=400). - Hann window variant:
torch.hann_window(N)(periodic) differs fromnp.hanning(N+1)[:-1](symmetric) by ~2.4e-7 per sample. Enough to cause cos_min≈0.95 on the mel comparison. Store the correct variant in the GGUF. - Frame count:
torch.stftproduces N+1 frames and drops the last one (stft[..., :-1]).core_mel::computemay produce 1 extra frame. Truncate:T_mel = n_samples / hop_length. - Filterbank layout:
core_mel::FbLayout::MelsFreqs=fb[m*n_freqs+k](numpy(n_mels, n_freqs)row-major). HFWhisperFeatureExtractorusesFreqsMels(transposed). Check which one your upstream model uses.
Models that interleave text + audio codec streams (mini-omni2-style):
- N input streams embedded and averaged — audio features replace pad positions in audio streams only (NOT the text stream)
- Special task tokens at stream end select the mode (ASR/TTS/S2S)
- For decode-step performance, batch all N token embeddings in one
ggml_get_rowscall instead of N separate graph builds
PyTorch model loading OOMs easily on this machine. Patterns:
- Load encoder, capture activations,
del model; gc.collect()before loading the LLM del state_dict; gc.collect()aftermodel.load_state_dict()- Use
--stages mel_spectrogram,encoder_outputto skip LLM loading entirely when only testing the audio pipeline
Apple-silicon Metal uses unified memory, so the CPU backend can read GPU buffers. That hides two whole classes of bug that abort on CUDA — always sanity-check a GPU-enabled backend on a real discrete GPU (Kaggle T4/P100), not just M1.
- Compute a
sched-allocated graph with thesched, never a raw backend. If you allocate withggml_backend_sched_alloc_graph(sched, gf), you MUST run it withggml_backend_sched_graph_compute(sched, gf). Callingggml_backend_graph_compute(backend_cpu, gf)instead dereferences GPU-resident weights on the CPU backend — a silent no-op on Metal, an illegal access on CUDA. (FastPitch shipped this on its decoder + vocoder graphs while the encoder graph was correct — §204.) core_convt::convt1d_decompis channel-major;convt1d_decomp_tfis time-first. The decomposed ConvTranspose1d's first op ismul_mat(w_perm[IC, K*OC], x), which needsx->ne0 = IC.core_hifigan(and any pipeline built fromggml_conv_1d) is time-major(T, C)—ne0 = T, notIC— so it must use the_tfvariant. The wrong one aborts onggml_can_mul_maton all backends. When amul_mat/conv asserts inside a shared vocoder, suspect the input's contiguous axis, not the weights (§204).ggml_backend_sched+ a weight-less first op corrupts the GPU graph. If a subgraph's leading op is weight-less (e.g. RMSNorm/LayerNorm before any matmul), the scheduler (op_offload=false) places that op and the leaf input on the CPU backend, then inserts a CPU→GPU copy of the activation to feed the next op — and on the current ggml revision that cross-backend copy is miscomputed, so the whole subgraph is garbage on GPU while CPU stays bit-correct. (Graphs whose first op uses a weight keep the input on the GPU and are fine — that's why encoders/adapters worked but the LFM2 backbone didn't, §206.) Diagnose withGGML_SCHED_DEBUG=2— look for a leaf input on[CPU]and#<backend>#...#0copy nodes. Gotcha: a standalone op test "on Metal" via the sched may silently run on CPU for both the CPU and Metal runs, trivially matching and masking the bug — confirm each op's real backend. Fix: when a whole subgraph is supported on the active backend, compute it directly withggml_gallocr+ggml_backend_graph_compute(ctx->backend, gf)instead of the scheduler — a single-backend graph never inserts the broken copy. (Pinning the input withset_tensor_backendorop_offload=truedid not help; only avoiding the split did.) Fixed lfm2-audio (§206) and kugelaudio (§209), which share this bug.- Mixing gallocr and sched when one graph has a Metal-unsupported op. If most
graphs need the gallocr fix above but ONE uses an op the active backend can't run
(kugelaudio's VAE decoder uses
ggml_pad— the causal-conv left-pad — which Metal rejects), gallocr has no fallback and aborts (unsupported op 'PAD'). Keep that one graph onggml_backend_sched(which falls back to CPU for the unsupported op; on CUDAPADis supported, so it runs fully on GPU) and run the rest on gallocr. This is safe as long as the sched-routed graph's first op uses a weight (input lands on GPU) — only the leaf-input + weight-less-first-op combo triggers the broken cross-backend copy; mid-graph CPU splits are fine. (Aggml_concat-of-a- scaled-view "manual pad" to avoidGGML_OP_PADfails when pad > input length — you can't build zeros wider than the source view.) - Host-syncing ops crash a CUDA-graph-captured graph.
ggmlcan capture and replay an LLM decode step as a singlecudaGraphLaunch(CUDA-graph capture, on by default viaGGML_CUDA_GRAPHS_DEFAULT, arch-gated to sm_80+; disable withGGML_CUDA_DISABLE_GRAPHS=1), but only if every op in the graph is capture-safe.get_rows_cuda_kquantcopies the index tensor D2H and callscudaStreamSynchronizeto read it on the host before launching its per-row dequant kernels — a host sync is illegal inside capture, so a graph containing GET_ROWS on a k-quant source crashes. F16/F32/legacy-quant GET_ROWS use the capture-safek_get_rowspath and are unaffected. (This bitgranite-speech-4.1-2b-q4_k.gguf's quantized token embedding under capture — §210.) Fix: deny CUDA-graph capture for graphs with k-quant GET_ROWS (ggml_cuda.cu), and keep the k-quant path on the legacy per-call rebuild. The same rule applies to any future op that host-syncs (e.g. reads a shape/extent on the host before dispatching) — it cannot live in a captured graph; route it through the non-captured path instead. Related: even on a topology-stable captured graph you must stillggml_backend_sched_reset+sched_alloc_graphper step — ggml's capture bookkeeping rejects the documented reuse-shortcut (ggml-backend.h:285) withCUDA error: invalid argumentmid-decode.
All TTS output is automatically watermarked (on by default). The mark can be
turned off with the --no-watermark CLI flag or the CRISPASR_NO_WATERMARK
env var — equivalent opt-outs, both emit a one-time stderr warning
(watermarking disabled. AI usage marking responsibility rests with the operator.). The warning text is deliberately jurisdiction-neutral and does not
name a specific statute; see docs/issue-260/PLAN.md for the regulatory
background (incl. EU AI Act Art. 50). When changing TTS output paths, ensure
these test suites still pass:
# Unit tests (no model needed)
build/bin/test_server_wav_writer # WAV LIST/INFO metadata
build/bin/test_watermark # Spread-spectrum embed/detect
build/bin/test_tts_provenance # ID3v2, C2PA, consent, edge cases
build/bin/test_audioseal "[unit]" # AudioSeal API surface
# Live tests (need AudioSeal GGUF)
python3 models/convert-audioseal-to-gguf.py -o audioseal.gguf
AUDIOSEAL_GGUF=audioseal.gguf build/bin/test_audioseal "[live]"
# Cosine parity with PyTorch (need reference .npy files)
AUDIOSEAL_GGUF=audioseal.gguf build/bin/test_audioseal_cosineFor ASR backends, the transcript is the regression target:
./build/bin/crispasr --backend yourmodel -m model.gguf -f samples/jfk.wav -np > before.txt
# ... make changes ...
cmake --build build --target crispasr-lib
./build/bin/crispasr --backend yourmodel -m model.gguf -f samples/jfk.wav -np > after.txt
diff before.txt after.txt && echo BIT-IDENTICALFor TTS backends the output is audio (not text), and diffusion
samplers are stochastic, so a diff of two runs won't compare. The
regression target is "audio cosine similarity vs the official
model's output, with the same Gaussian noise pinned in both runs":
# 1. Run the official PyTorch model with hooks that capture the
# per-frame init noise plus the conditions / latents per frame
HF_HOME=/path/to/hf-cache python tools/run_official_vibevoice.py \
--text "Hello, how are you today?" \
--voice voices_pt/en-Emma_woman.pt \
--output-wav /tmp/ref.wav \
--output-dir /tmp/ref_dump
# 2. Run crispasr with the same noise pinned and per-frame dumps
VIBEVOICE_TTS_NOISE=/tmp/ref_dump/noise.bin \
VIBEVOICE_TTS_DUMP=/tmp/cpp_dump VIBEVOICE_TTS_DUMP_PERFRAME=1 \
./build/bin/crispasr --tts "Hello, how are you today?" \
-m vibevoice-realtime-0.5b-tts-f16.gguf \
--voice vibevoice-voice-emma.gguf \
--tts-output /tmp/cpp.wav -ng
# 3. Audio cos at xcorr peak (accounts for any leading-silence trim)
python -c "import sys; sys.path.insert(0, 'tools'); \
from _audio_diff import cos_report; \
print(cos_report('/tmp/ref.wav', '/tmp/cpp.wav'))"
# OFFICIAL: 182400 samples = 7.60s rms=0.0653
# AFTER_FIX: 171459 samples = 7.14s rms=0.0672
# cos at zero shift = 0.0027
# cos at xcorr peak = 0.9991 lag=7741 samples = 322.5 mscos at xcorr peak ≥ 0.999 is "essentially bit-exact modulo F16
quantization". A drop indicates a real divergence — pair the audio
diff with the per-frame stage diff (next section) to localise.
Bit-identical regression against the previous C++ version proves the change was neutral, but it doesn't tell you the C++ forward pass is correct in the first place. For that, use the ground-truth tools:
# 1. Capture PyTorch reference activations at every named stage
python tools/dump_reference.py --backend voxtral \
--model-dir /path/to/hf/voxtral-mini-3b-2507 \
--audio samples/jfk.wav \
--output /tmp/voxtral-ref.gguf
# 2. Compare your C++ forward pass against the reference, stage by stage
./build/bin/crispasr-diff voxtral \
voxtral-mini-3b-2507-q4_k.gguf \
/tmp/voxtral-ref.gguf \
samples/jfk.wav
#
# [PASS] mel_spectrogram shape=[128,3000] cos_min=0.99998 max_abs=3e-5
# [PASS] projector_output shape=[375,3072] cos_min=0.99985 max_abs=4e-4
# summary: 2 pass, 0 fail, 0 skip (cos threshold 0.999)crispasr-diff works for TTS backends too, not only ASR. For TTS,
the 4th argument (audio.wav) is ignored — pass any valid WAV (e.g.
samples/jfk.wav). Text and other TTS inputs come from env vars
(<BACKEND>_SYN_TEXT, ZONOS_TTS_TEXT, CHATTERBOX_SYN_TEXT, …).
See the chatterbox dispatch in crispasr_diff_main.cpp as the
canonical TTS template; zonos-tts follows the same pattern.
# TTS example — conditioning_prefix stage for Zonos:
ZONOS_TTS_TEXT="Hello world." ZONOS_SPEAKER_EMB_PATH=/path/to/jfk_speaker_emb.bin \
python tools/dump_reference.py --backend zonos-tts \
--model-dir Zyphra/Zonos-v0.1-transformer \
--audio samples/jfk.wav \
--stages conditioning_prefix \
--output /tmp/zonos-ref.gguf
ZONOS_TTS_TEXT="Hello world." ZONOS_SPEAKER_EMB_PATH=/path/to/jfk_speaker_emb.bin \
./build/bin/crispasr-diff zonos-tts \
zonos-v0.1-transformer-q4_k.gguf \
/tmp/zonos-ref.gguf \
samples/jfk.wav
# [PASS/FAIL] conditioning_prefix shape=[2048,22,2] cos_min=… cos_mean=…The Python dumper uses PyTorch forward hooks to capture intermediate
activations (mel, per-encoder-layer output, projector, LLM block
output, logits, argmax) and writes them to a single GGUF tensor
archive. The C++ side loads the archive via
core_gguf::load_weights and runs the backend's public stage helpers
(*_compute_mel, *_run_encoder, etc.) to produce the same tensors,
then the shared crispasr_diff::Ref compares them with cosine
similarity per row, max-abs error, RMS, and — for logits —
top-1 argmax match rate.
Adding a new backend to the dumper is a ~60-line file in
tools/reference_backends/<name>.py that registers PyTorch forward
hooks and returns a dict {stage_name: ndarray}. Worked examples:
tools/reference_backends/qwen3.py,voxtral.py,cohere.py,parakeet.py,gemma4.py,omniasr_llm.py,granite.py— encoder-decoder / Audio-LLM ASR backends. Use the_hooks.pyforward_hook helpers (capture_modules,drop_hooks,finalize).tools/reference_backends/qwen3_tts.py,qwen3_tts_codec.py,qwen3_tts_spk.py,qwen3_tts_cenc.py— TTS prefill / encoder backends. Usecapture_modules(..., first_call_only=True)for hooks that fire once per stage (e.g. talker prefill called from insidegenerate()).tools/reference_backends/vibevoice.py,vibevoice_tts.py— VibeVoice σ-VAE encoder + TTS pipeline.
For autoregressive / diffusion-sampler diffs the per-stage capture above isn't enough — bugs that appear only after several AR steps don't show up in a frame-0 cos diff. Two extra helpers:
tools/reference_backends/_iter_capture.py— companion to_hooks.pyfor "monkey-patch a sampler entry point and append one tensor per iteration to a{stage: [...]}dict". Used to capturepos_cond / neg_cond / noise / v_cfg_step0 / latentper frame insidesample_speech_tokensfor vibevoice.tools/_audio_diff.py— sample-wise audio cos at zero shift, cos at the cross-correlation peak (so leading-silence trims and causal-padding offsets don't tank the score), spectral band-power table, and a one-callcos_report(a_path, b_path)for CLI use.
tools/run_official_vibevoice.py is the worked example combining
all three: it loads the upstream model, monkey-patches
sample_speech_tokens via _iter_capture.patch_method, captures
acoustic_embed via a standard forward_hook, and writes both
perframe_<stage>_f<NNN>.bin files (matching the C++ runtime's
VIBEVOICE_TTS_DUMP_PERFRAME=1 output) and a noise.bin for the
C++ side to replay.