Skip to content

[pull] main from CrispStrobe:main - #10

Open
pull[bot] wants to merge 3840 commits into
CKwasd:mainfrom
CrispStrobe:main
Open

[pull] main from CrispStrobe:main#10
pull[bot] wants to merge 3840 commits into
CKwasd:mainfrom
CrispStrobe:main

Conversation

@pull

@pull pull Bot commented May 23, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

@pull pull Bot locked and limited conversation to collaborators May 23, 2026
@pull pull Bot added the ⤵️ pull label May 23, 2026
CrispStrobe and others added 28 commits July 29, 2026 10:12
whisper_vad_build_lstm_layer fed a transpose VIEW straight into a matmul:

    ggml_tensor* x_t = ggml_transpose(ctx0, cur);          // non-contiguous
    ggml_tensor* inp_gate = ggml_mul_mat(ctx0, model.lstm_ih_weight, x_t);

ggml_transpose swaps nb[0]/nb[1], so x_t's row stride becomes sizeof(float) and
llamafile_sgemm's `ldb` collapses to 1 while k is lstm_hidden_size (128) —
tripping its `ldb >= k` precondition (ggml-cpu/llamafile/sgemm.cpp:3700). Fixed
with ggml_cont, which materialises the transpose; that is the idiom the rest of
this codebase already uses (src/audioseal.cpp, five sites). The VAD was the
outlier.

WHAT MADE IT INVISIBLE, which is the part worth remembering. Release defines
NDEBUG, so the assert is compiled out and the matmul RAN ANYWAY with a violated
stride precondition rather than stopping — producing plausible-looking segments.
Only a Debug build aborted, and the Debug leg had never run: CI executed 1 of 162
unit tests until 2026-07-29. Turning the unit tier on is what surfaced this.

I initially misread it as a brittle test (hard-coded "344 probs / 4 segments"
flipping between -O0 and -O2). The assertion text settled it: the test was right,
the code was wrong.

Verified on Kaggle before pushing, A/B on ONE tree, both compilers, 8/8 checks:

    before  gcc/clang Debug    rc=134, assertion `ldb >= k`, 0 segments
    after   gcc/clang Debug    rc=0,   4 segments
    before  gcc/clang Release  rc=0,   4 segments
    after   gcc/clang Release  rc=0,   4 segments  (BYTE-IDENTICAL)

The Release half matters as much as the Debug half: a precondition fix must not
smuggle in a behaviour change, so "the abort is gone" and "the output did not
move" are checked as separate claims. tools/kaggle/vad-sgemm-fix/ carries the
harness.
Two follow-ups from running the unit tier for the first time.

**python3.** The sanitizer legs run inside a container whose apt line was
`build-essential cmake git curl`. test-release-workflow is a python test, so the
moment the tier started running there it failed with "/usr/bin/env: 'python3': No
such file or directory". Not a code bug — a missing dependency that only became
visible once the tests actually ran. (The runner-hosted jobs have python3, which
is why they pass it.)

**GGML_NATIVE=OFF on the x86 gcc and clang jobs.** These built -march=native,
so their numerics depended on whichever CPU the runner drew. The arm64 job in
this same file already pinned it, and release.yml pins it for every shipped
binary; the x86 legs were the outliers.

Being straight about what this does and does not do: it is a REPRODUCIBILITY fix,
not a fix for the core_adaln failure. That failure remains OPEN and un-root-caused
— see PLAN.md for the five hypotheses eliminated by measurement (clang in general,
the clang version, -march=native on a real AVX-512 box, llamafile_sgemm, and the
sanitizers' view). It is not reproducible on the hardware available to me because
GitHub's runners expose VNNI/BF16 that box does not, and ggml has paths for both.
Shipped artifacts were never affected.
… trigger

Pinning the ISA turned both clang legs green, which is positive evidence rather
than an absence: the trigger is native codegen on the runner's CPU. My earlier
'NOT -march=native' conclusion was a false negative — the box I reproduced on has
avx512f/bw/cd/dq/vl but no VNNI/BF16, which GitHub's runners have and ggml has
kernels for. Compilable there, not executable. Root cause is an upstream ggml CPU
kernel, not core/adaln.h.
The failure was never x86. `ubuntu-22-clang`'s matrix has `include:` entries that
do not key on `build:`, so GitHub merges them and the LAST wins — every
"ubuntu-22-clang" job actually ran on ubuntu-22.04-arm. Its cmake output says so
outright:

  -- Adding CPU backend variant ggml-cpu: -mcpu=native+dotprod+i8mm+sve+nosme

aarch64 with SVE. That explains why no amount of AVX-512 flag-twisting on an x86
box reproduced it, and why GGML_NATIVE=OFF cures it — that drops the +sve. It also
explains an existing oddity: ubuntu-22-gcc-arm64 in the same workflow already pins
-DGGML_NATIVE=OFF -DGGML_CPU_ARM_ARCH=armv8-a, i.e. someone hit ARM trouble there
before and worked around it without writing down why.

GitHub's ARM runners are the only SVE hardware available here, so the bisect runs
there: native (+sve) to reproduce, an explicit arch WITHOUT +sve to test whether
SVE alone is responsible, and GGML_NATIVE=OFF as the control. Plus a per-stage
dump — the six modulation views ARE the projection output, so divergence there
implicates modulate6's mul_mat while a clean set with a bad `out` implicates the
norm chain.

Dispatch-only; nothing runs it automatically. test-core-adaln links just Catch2 +
ggml, so each config is a small build.
… ARM)

Bumps the ggml pin bfe8ea22 -> 392ac397, which cherry-picks upstream
6aab1bcb "ggml-cpu: fix SVE leftover path in ggml_vec_dot_f32 (llama/24699)"
onto crispstrobe-ops with authorship intact (Tarek Dakhran, 2026-06-26).

THE BUG. ggml_vec_dot_f32's SVE tail was:

    sum1 = svmad_f32_m(pg, ax1, ay1, sum1);

svmad_..._m computes a*b + c but MERGES ON THE FIRST OPERAND, so inactive lanes
take ax1 — which the predicated load had zeroed. That wipes the lanes the
preceding leftover loop had already accumulated. Upstream's fix, which is exactly
what this analysis arrived at independently:

    sum1 = svmla_f32_m(pg, sum1, ax1, ay1);   // merges on sum1, lanes preserved

It only bites when n >= epr && n % epr != 0, which is why normal LLM dims (multiples
of 32) never hit it. Upstream found it via 2D convolutions with kernel size 9; we
hit it with core_adaln's dim=6 (epr=4 at VL=128: 4 lanes accumulated, tail zeroes 2).

HOW WE GOT HERE, because the trail was misleading. test-core-adaln failed only in
build.yml's `ubuntu-22-clang` legs. That job name is wrong: its matrix `include:`
entries do not key on `build:`, so GitHub merges them and the LAST wins — every
"ubuntu-22-clang" job actually ran on ubuntu-22.04-arm. Its cmake line said so:

    -- Adding CPU backend variant ggml-cpu: -mcpu=native+dotprod+i8mm+sve+nosme

aarch64 + SVE, never x86 — which is why no amount of AVX-512 flag-twisting on an
x86 box reproduced it. A bisect on real SVE2 hardware then isolated it exactly:
native(+sve) diverged on ALL SIX modulation views (0.21-1.32) plus out (0.66/1.96),
while the same arch WITHOUT +sve and GGML_NATIVE=OFF were 0.000000 everywhere. The
views ARE the projection output, so the fault was in modulate6's mul_mat, not the
norm chain — which is what pointed at ggml_vec_dot_f32.

So the answer to "what do we already fix in our fork" is the opposite here: we were
BEHIND upstream, not ahead. No PR to file.

STILL OPEN: the F16 tail at vec.cpp:334 is `svmad_f16_x(pg, hx, hy, sum1)`. `_x`
leaves inactive lanes UNSPECIFIED after an accumulating loop — correct only by
codegen luck. Upstream has since reworked that path (ggml_sve_f16_fma_widened);
not cherry-picked here because it is a larger change and F16 dots are not what this
bug report covers.
Added while core_adaln was unexplained; keeping it now would permanently blind CI
to ISA-specific kernel bugs, which is exactly the class it just caught. The real
fix is in the ggml pin (fb7972a): upstream's svmla_f32_m leftover-lane fix.

Verified on real SVE2 hardware that native(+sve) is 0.000000 on every stage after
the pin bump, so restoring native is safe and restores the coverage.

ubuntu-22-gcc-arm64 keeps its own -DGGML_NATIVE=OFF -DGGML_CPU_ARM_ARCH=armv8-a;
that predates this work and is not mine to churn.
**ggml pin 392ac397 -> 52165e4c** cherry-picks upstream f69bdbb3 "ggml: fixed Arm
SVE usage bug in vec.h, vec.cpp (llama/22841)" (Martin Klacer + Milos Puzovic,
Arm, 2026-05-28) with authorship intact.

This is the F16 half of the SVE problem, and "upstream reworked that path" deserves
spelling out: they did NOT swap svmad_f16_x for a merging variant. They changed the
accumulation STRATEGY — F16 accumulators became paired F32 (sum_lo/sum_hi) and
every multiply-accumulate now goes through ggml_sve_f16_fma_widened(), which widens
F16->F32 before accumulating. The tail uses that same helper, so there is no
predicated FMA left to get wrong: the predicated load's zeroed lanes just
contribute 0*0. It is a precision fix (F32 accumulation) that removes the
predication hazard structurally instead of patching it.

Checked, NOT changed: vec.h:396 and :513 still use svmad_f32_m / svmad_f16_x, but
both are immediately followed by a PREDICATED STORE (svst1_*(pg, ...)), so inactive
lanes are never written back. Upstream carries them verbatim. Not bugs.

**The clang matrix.** `ubuntu-22-clang` declared arch via two bare `include:`
entries sharing no key with `build:`. GitHub merges those into every combination,
so the LAST won and every such job ran on ubuntu-22.04-arm: clang-on-x86 has never
been tested, and the arm64 runs were labelled as if they were x86. That mislabelling
is what sent the core_adaln investigation chasing "a clang bug" and then AVX-512 on
x86 for three rounds when the cause was aarch64 SVE. `arch` is now a real matrix
dimension and `include:` only maps arch -> runner, which is what it is for: 4 jobs
instead of 2, and two of them are coverage that never existed.
…matrix

F16: upstream f69bdbb3 (Arm) replaces F16 accumulators with paired F32 via
ggml_sve_f16_fma_widened, removing the predicated FMA entirely rather than
swapping the intrinsic. vec.h:396/:513 checked and left alone — predicated stores
mean inactive lanes are never written back, and upstream carries them verbatim.

Matrix: arch is now a real dimension, so clang-on-x86 is tested for the first time
and arm64 runs are no longer mislabelled as x86.
…take

MASTER-AUDIT.md tracks what we send upstream. Nothing tracked the other
direction, which is why core_adaln failed on ARM for weeks: our vendored ggml
predated TWO upstream SVE fixes and nobody was looking.

crispstrobe-ops is 11 ahead / 435 behind, base v0.10.2 vs upstream v0.17.0. Of the
11, two are today's cherry-picks, so nine are genuinely ours.

Priorities are ranked by what CrispASR actually runs, not by upstream's own
severity. Priority 1 is conv1d/im2col on Metal (b40b6928 says "audio models" in
its subject; M1 is the primary dev box), a simd_gemm TAIL-COLUMN indexing fix
which is the same class of bug as the SVE one we just fixed, quantized concat,
and rms_norm_back under in-place aliasing. Then Vulkan (we ship it on Windows and
have four standing Vulkan-only TTS failures) and CUDA.

Two collisions found that must be settled before any sync:

  * upstream now has its own col2im_1d (fda9d536). We carry ours (PR #160) behind
    a "MUST RE-APPLY after ggml bump" marker in ggml-metal-device.m. They will
    collide. upstream-prs/20 is therefore SUPERSEDED — adopt upstream's, drop ours.
  * conv_transpose_1d arrives twice: a056a26f is our OWN merged PR (ggml#1477) and
    we also carry it locally. Not a gap, but it will present as a conflict.

Recommendation: take Priority 1 now, then do a real sync rather than more
cherry-picks. Seven minor versions of drift plus manual re-apply markers is what
produced the SVE miss; each further cherry-pick applies against more foreign
context. MASTER-AUDIT.md is itself 435 commits stale and wants re-running.
Russian ASR, 220 M params. One runtime serves all four ASR revisions —
`ctc` / `rnnt` (33 Cyrillic chars, lowercase) and `e2e_ctc` / `e2e_rnnt`
(SentencePiece, punctuation + casing + ITN) — the GGUF carries the head
type and tokenizer kind.

Per-stage parity vs a PyTorch reference dumped from the upstream
`modeling_gigaam.py`, on GigaAM's own example.wav: F16 is cos 1.000000 at
mel, pre-encode, all 16 conformer layers, the encoder output and every
head stage, with a byte-identical transcript. Q8_0 holds the transcript
byte-identical on all four variants (encoder cos 0.997-0.999), so it is
the registry default; Q4_K stays exact on the charwise variants and costs
a few capital letters on the SentencePiece ones.

Three blueprint details that the shapes do not catch:
  * RoPE is applied to the block INPUT before the Q/K/V projections, so
    Q = Wq*RoPE(x), K = Wk*RoPE(x) and V = Wv*x — V is unrotated.
  * The rotary base is pos_emb_max_len = 5000, not 10000, because
    RotaryPositionalEmbedding's second positional arg is `base`.
  * conv.batch_norm is an nn.LayerNorm (conv_norm_type='layer_norm') —
    affine only, no running stats to fold.

Two integration bugs the diff harness cannot see (it ends at the logits):
  * the charwise revisions auto-enabled FireRedPunc, a Chinese/English
    restorer, which injected full-width CJK punctuation into Russian
    ("надеждой, сладкой ... зеленый。"). CAP_PUNCTUATION_NATIVE is now
    declared for every revision; an explicit --punc-model still applies.
  * `-l auto` ran a whisper-tiny LID pass on a Russian-only model (#227);
    sole_language() == "ru" skips it (21.3x -> 43.4x realtime on M1).

The FFN / conv / macaron halves reuse core_conformer::BlockWeights; only
the attention differs, so the block builder is local rather than a rel-pos
core_conformer::build_block call. The quantizer keeps encoder.pre.* (the
mel is un-normalized log-mel) and joint.* / decoder.* / head.ctc.* at
source precision.

Weights: https://huggingface.co/cstr/gigaam-v3-GGUF
Notes + follow-ups: docs/gigaam/PLAN.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… equivalent

An earlier draft said 'adopt upstream's and delete ours'. Comparing the two
headers during the merge shows that is wrong and would have been silent:

    upstream  scatter-add, p0 = crop BOTH sides, T_out = ... - 2*p0
    ours #160 gather,      p0 = LEFT offset,     T_out = ... -   p0

Same name, same signature, different semantics. src/core/conv.h:166 passes
crop_left as p0 and trims crop_right with a view, i.e. it relies on ours.
Swapping in upstream's compiles and links and changes ConvTranspose1d output
length in every TTS decoder — wrong audio, no error.

Adopting upstream's requires rewriting convt1d_decomp (p0=0 + views for both
crops) and validating against TTS audio before any pin bump.
Attempted the full merge: crispstrobe-ops <- ggml-org/ggml master. 435 commits,
19 conflicted files, 53 hunks. Resolved 3 and stopped deliberately.

Resolved: gguf.cpp (upstream implemented our empty-key rejection independently,
so 1dc4cb93 is superseded), test-quantize-fns.cpp (upstream superset), and
ggml-metal-device.m (kept BOTH — our pipeline cache and upstream's new
device_id_parse are adjacent additions, not an overlap).

Two blockers make the rest unsafe to finish blind, and both fail SILENTLY:

1. GGML_OP_* is an ORDERED enum with parallel name/symbol tables in ggml.c. Ours
   asserts COUNT==99, upstream ==101; both sides inserted ops at different
   positions. Merged wrong, ops dispatch to the WRONG KERNEL with no compile
   error. The enum and both tables have to be re-derived entry-by-entry.

2. col2im_1d is TWO DIFFERENT OPS sharing a name and signature (scatter-add with
   symmetric crop vs our gather with left offset). src/core/conv.h depends on
   ours; taking upstream's changes ConvTranspose1d output length in every TTS
   decoder, compiling cleanly the whole way. It drives 4 of the conflicted files.

The inventory records per-file guidance for the remaining 16 so this does not
have to be re-derived: which hunks are pure additions of ours to keep (the
588-line CUDA peer/VMM block, the im2col grid.y clamp), which are genuine
overlaps needing judgement (fattn RDNA4 gate, cpy.cu GH#65 vs upstream's rework,
the issue-#38 im2col_type selection), and which are blocked on blocker 2.

CrispASR's pin stays at 52165e4c — it has both SVE fixes and is the known-good
tree. Bumping it from a partially-resolved merge is exactly the wrong move.
`tools/check-backend-wiring.py` reported PASS, but walking
docs/contributing.md by hand found four things it does not check:

* the session C ABI never forwarded the decode knob. The CLI adapter
  called gigaam_set_max_symbols(), crispasr_c_api.cpp did not — so
  --max-new-tokens worked on the CLI and silently no-op'd for every
  binding and the server. This is the multi-surface trap the guide calls
  the #1 recurring bug (#292/#315): a fix in one surface never reaches
  the others.
* docs/environment-variables.md listed all five CRISPASR_GIGAAM_* gates
  nowhere; every other backend's are enumerated there.
* tools/reference_envs/gigaam/requirements.txt was missing, so
  `tools/bootstrap_ref_env.sh gigaam` could not build the dump env.
* docs/diff-harness-coverage.md is generated by audit_diff_coverage.py
  and had never been regenerated after the backend landed.

The audit script checks factory / c_api dispatch / available_backends /
feature-matrix / registry and is worth running, but it does not read
contributing.md's per-section requirements. Both passes are needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First half of the FoxNoseTech/diarize port: the 256-dim speaker
embedder its clustering consumes. Clean-room from the architecture —
the upstream (wenet-e2e/wespeaker, Apache-2.0) was read to establish
the spec and is imported by the reference dumper as an ORACLE, but no
expression from it is reproduced, so the repo stays MIT.

Per-stage parity vs that oracle on samples/jfk.wav:

  fbank        cos_mean 0.999999
  stem/layer1-4 cos_mean 0.99997 - 0.999995
  stats        cos_mean 0.999999
  embedding    cos_mean 0.999997,  cosine(emb, ref) = 0.99999747

Details that decide correctness, all traced to the driving code rather
than assumed:

  * int16-SCALE waveform — wavform_norm defaults to False upstream, so
    kaldi.fbank sees a +/-32768 signal; hamming window, per-utterance
    CMN.
  * the 2-D map is HEIGHT=freq, WIDTH=time. TSTP reduces over time then
    flattens (channel, freq) with freq fastest, and seg_1's 5120 columns
    are in that order — transposing the map silently permutes the stats
    vector against its weights.
  * TSTP std uses torch's UNBIASED (n-1) variance, +1e-7 inside sqrt.
  * output is seg_1(stats) raw: no ReLU, no BN, no L2 norm.
  * BN folded into every conv at convert time (219 -> 74 tensors); the
    ArcMargin `projection` head is training-only and dropped
    (11.25M -> 6.6M params).

Two ggml findings worth recording:

  * F16 conv kernels trip GGML_ASSERT(src1->type == GGML_TYPE_F32) in
    the CPU conv_2d_direct path. Conv kernels are pinned to F32 by the
    converter; F16 on the 2-D linear is fine and gives an identical
    embedding (0.99999744).
  * ggml_add1(ggml_new_f32(...)) is invalid in a no_alloc graph context
    — ggml_new_f32 writes to tensor->data at build time, which is null.
    ggml_scale_bias folds the constant into the op instead.

cos_min on the post-ReLU maps is a brittle statistic (36% of stem_out's
rows are entirely dead); the harness now prints the dead-row counts so
a cos_min of 0.000000 is interpretable rather than alarming. Judge this
backend on cos_mean and on `embedding`.

Live tests cover the property the diarizer actually rests on: two
windows of the same speaker score cos 0.595 against 0.100 for a
different speaker. ⚠ samples/multispeaker.wav opens with all 11 s of
samples/jfk.wav verbatim, so a cross-speaker window has to be taken
past that boundary — the first draft of the test scored cos = 1.000000
and was measuring jfk against itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clustering half of the FoxNose diarization recipe: PCA(8) -> full-covariance
GMM BIC sweep -> cosine-affinity spectral clustering -> silhouette refinement
-> spherical centroid refinement.

Clean-room, so the repo stays MIT. There was nothing to translate: the
upstream clustering.py is a sequence of scikit-learn CALLS, and C++ has no
sklearn, so the numerics are written from the published algorithms. What
comes from upstream is the recipe and the tuned constants (p10 >= 0.16,
0.04*log k, PCA=8, the k-window, n_init/max_iter) — parameters and facts,
not copyrightable expression.

Bit-exact sklearn parity is unachievable here and always would be: its
k-means++ seeding, GMM init and ARPACK eigensolver all ride its own RNG
stream. So the gate is known-answer unit tests plus DER, not label equality.
Determinism across runs IS guaranteed — everything is explicitly seeded.

Two measured findings, both recorded as tests rather than folklore:

* The upstream ceiling of n/2+1 components is far too loose for a FULL
  covariance. A d x d covariance is not estimable from fewer than d+1
  points, so past n/(d+1) every extra component fits a near-singular
  Gaussian whose density diverges, BIC falls monotonically and the sweep
  runs away to max_k. Measured on 3 blobs (n=60, pca_dim=8): BIC dropped
  881 -> 138 straight through k=10. reg_covar does not save this —
  sklearn's 1e-6 default is negligible against PCA noise-component
  variances of ~0.03. Bounded by n/(d+1); that alone fixed true k=2.

* The [k-2, k+3] silhouette window recovers a BIC UNDER-count (true 4/5/6
  anchored at 2/2/3, all recovered exactly) but not a large OVER-count
  (true 3 anchored at 8 leaves the window at [6,10]). Silhouette itself is
  the reliable half — it scored the true k at 1.0390 against 0.68/0.79 for
  its neighbours. CRISPASR_DIARIZE_FULL_K_SEARCH=1 scores the full range
  instead: 5/5 exact vs 4/5 for the window.

The full search is GATED OFF and the upstream window stays the default
until the DER harness has a say — gate the new path, do not delete the
working one. The unit test pins BOTH arms so neither regresses unnoticed.

23 cases / 256 assertions, all hermetic: no model, no audio, no network.
Synthetic blob centres point in random directions rather than along
coordinate axes, because axis-aligned centres make the between-blob signal
rank k-1 and hand the GMM six dimensions of near-degenerate noise — which
is what surfaced the ceiling bug in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second half of the clustering stack. Clustering labels each embedding
window independently, so its output flickers — a window straddling a
pause, or one noisy embedding, flips the label mid-utterance. This
imposes temporal continuity:

  majority_label / smooth_window_labels  3-window majority, ties keep the
                                         original label
  speaker_centroids                      L2-normalised centroid per label,
                                         mean of NORMALISED members
  viterbi_smooth                         max total score with a 0.18
                                         penalty per label change
  smooth_segment_temporal                score windows against centroids,
                                         anchor the original label by 0.02,
                                         Viterbi-decode
  collapse_short_islands                 absorb an A-B-A island <= 1.2 s
  restore_sustained_runs                 put back original runs > 1.2 s that
                                         Viterbi flattened

Clean-room on the same basis as core/spectral_diarize: Viterbi decoding
and majority filtering are textbook; what comes from upstream is the
order of operations and the constants.

Three behaviours that are easy to get subtly wrong and are pinned by
tests rather than left to comments:

  * a TIE is not a majority. Returning a winner would let the 3-window
    filter manufacture a label exactly at a speaker boundary — the one
    place the sequence is genuinely ambiguous.
  * centroids average the NORMALISED members. Averaging raw vectors lets
    a loud window dominate its speaker's centroid; the test uses members
    with 5x magnitude differences so the two disagree.
  * only an A-B-A sandwich is flicker. A-B-C is a genuine three-way
    change and must survive however short B is.

17 cases / 30 assertions, hermetic. The switch penalty is bracketed: a
0.10 score margin does NOT flip the label at the shipped 0.18 penalty
but DOES at penalty 0, so retuning the constant fails the test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ties the pieces together — speech regions -> sliding embedding windows ->
clustering -> temporal smoothing -> merged turns — and adds the metric
that judges the result.

DER is the right gate here because nothing else fits: there is no
transcript to compare as in ASR, and no per-stage reference to diff as in
a model port (the clustering is weight-free and sklearn parity is
unachievable). core/der.h follows the NIST/dscore conventions: a collar
around reference boundaries, an optimal 1:1 speaker mapping, and total
reference speech as the denominator. The mapping is EXACT by enumeration
up to 8 speakers and greedy beyond, and says which it used rather than
letting a caller mistake an approximation for a guarantee.

The pipeline takes its embedder as a FUNCTION POINTER rather than linking
one in. That keeps core/ free of model dependencies, lets the wiring layer
pick WeSpeaker or TitaNet — and above all makes the orchestration testable
with a synthetic embedder whose speakers are known by construction. A
model-driven test cannot separate "the pipeline is wrong" from "the
embedder is weak"; this one can. End-to-end on a synthetic 8-turn,
2-speaker timeline: DER 0.0.

Two things the tests pinned down:

* The synthetic speaker centres are ORTHOGONALISED, and that is
  faithfulness rather than convenience. Two random 32-D directions have
  cosine std ~= 1/sqrt(32) ~= 0.18, so a draw can exceed
  kSingleSpeakerSimP10 = 0.16 and trip the single-speaker veto on a
  genuinely two-speaker timeline — the first version of this test did
  exactly that and reported 1 speaker at DER 0.5. Real WeSpeaker
  embeddings measure ~0.10 cross-speaker, well under the threshold.
  Worth knowing: the veto is one global threshold, so two similar-sounding
  speakers can still be merged.

* "Total confusion scores 1" is wrong as usually stated — with a 1:1
  mapping, swapping the labels of a symmetric 2-speaker reference is FREE.
  The test forces real confusion by collapsing the hypothesis onto one
  speaker instead.

11 DER/pipeline cases, all hermetic. Whole new suite: 375 assertions
across 57 cases (der, spectral, smooth, wespeaker-params).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
--diarize-method foxnose --diarize-embedder <wespeaker.gguf>, with
--diarize-max-speakers / --diarize-num-speakers.

Plugs into the existing crispasr_diarize_segments contract: the caller's
segments ARE the speech regions (they come from ASR/VAD upstream), so this
method deliberately runs no VAD of its own — re-segmenting would duplicate
work and desynchronise labels from the segments they attach to.

--diarize-embedder was already claimed by the TitaNet remap at THREE call
sites (cli.cpp, crispasr_run.cpp, crispasr_server.cpp), which tried to load
the WeSpeaker GGUF as TitaNet and failed with a confusing
"block_repeats/kernels array size mismatch". All three now ask one shared
predicate, params.diarize_embedder_is_foxnose(), so a fourth cannot drift.
Gating only cli.cpp was not enough — the unified crispasr_run path is the
one actually taken, and only an end-to-end run surfaced that.

MEASURED on samples/multispeaker.wav (31.5 s, opens with all 11 s of
samples/jfk.wav then changes speaker):

  num_speakers=2 pinned      2 speakers, boundary at 10.5 s vs true 11 s
  auto, max_speakers=4       2 speakers, same correct turns
  auto, max_speakers=8       8 speakers, heavy flicker
  auto, max=8 + FULL_K_SEARCH  8 speakers, gate does not help

So the embedder, clustering and smoothing are right; AUTOMATIC SPEAKER
COUNTING is the open problem. Silhouette saturates and rises monotonically
to the ceiling on real embeddings: within-speaker cosine is ~0.595 against
~0.100 cross-speaker, so splitting a speaker cuts the intra term sharply
while the inter term barely moves, and the +0.04*log(k) bonus then pushes
to the top of the range. Upstream defaults max_speakers to 20, which is
why their README concedes it struggles past 8 speakers.

Settling that needs DER on labelled audio, of which the repo has none —
see docs/foxnose-diarize/PLAN.md. Until then the honest advice is a
conservative --diarize-max-speakers (4-6) or pinning the count.

Known limitation, documented: labels are attributed at SEGMENT
granularity, so an ASR emitting one 26 s segment across several speakers
gets a single label however good the turns are. Splitting at turn
boundaries, as the pyannote path does, is the follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-speakers

THIRD_PARTY_NOTICES.txt gains its first CC-BY-4.0 entry. The WeSpeaker
weights carry an ATTRIBUTION REQUIREMENT, and several downstream projects
(including the issue's own reference) describe them as Apache-2.0 — the
wenet-e2e/wespeaker CODE is Apache-2.0, the published WEIGHTS are not. The
notice also records that no upstream source is incorporated: the converter,
runtime and clustering are independent implementations, and the upstream
packages are used only as measurement oracles by the reference dumper.

--diarize-max-speakers now defaults to 4 for this method rather than
upstream's 20, because the measurement says a loose bound is actively
harmful: on samples/multispeaker.wav a bound of 8 yields 8 speakers with
heavy flicker while 4 yields the correct 2. An explicit flag always wins,
via a diarize_max_speakers_explicit flag following the same contract as
max_new_tokens_explicit (#292) — a per-method default must never override
a value the user chose.

docs/architecture.md#foxnose-diarize documents the pipeline, the licensing
position, why sklearn parity is unachievable, and BOTH known weaknesses:
speaker counting, and segment-granularity label attribution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without this the method was of limited use: labels attached at the
CALLER's segment granularity, so an ASR emitting one 26 s segment across
several speakers received a single label however good the derived turns
were. Measured on samples/multispeaker.wav, both ASR segments collapsed to
"(speaker 0)".

crispasr_diarize_segments now returns the turns it derived through a new
optional out_turns parameter (FoxNose only — the other methods label
caller segments directly and leave it empty), and the CLI splits any
multi-speaker segment at word-aligned boundaries. Only the per-word
labelling is new: a turn-interval lookup replaces pyannote's posterior
scoring, and everything after it reuses the existing
group_words_into_speaker_runs grouping and sub-segment emission.

After: the first slice reads 0 -> 1 -> 0 across the ~11 s boundary, which
matches the whole-file pipeline's turns.

⚠ Documented, not fixed: labels are still not consistent ACROSS slices.
Long audio is cut into slices and each is diarized independently,
restarting its numbering at 0 — so the final turn reads "speaker 0"
locally where the whole-file pipeline says SPEAKER_01. The pyannote path
solves this with a global cache computed once over the full audio (#107);
FoxNose needs the same. Until then this method is trustworthy only on
audio short enough to form a single slice. Recorded in
docs/architecture.md#foxnose-diarize and docs/foxnose-diarize/PLAN.md
rather than left to be rediscovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per-slice diarization cannot give consistent speaker identities: each
slice clusters independently and restarts numbering at 0, so "speaker 0"
in one slice is a different person from "speaker 0" in the next. On
samples/multispeaker.wav (2 slices) the final turn came out speaker 0
where the whole-file pipeline says SPEAKER_01.

FoxNose now runs ONCE over the whole audio after transcription
(crispasr_apply_foxnose_global), using the final segment list as its
speech regions. The pyannote path solves the same problem with a
posterior cache computed BEFORE transcription (#107); foxnose cannot do
that, because it needs the segments as its speech regions and they do not
exist yet — so it hooks the existing global speaker stage instead. The
per-slice path stands down via params.diarize_foxnose_global, which also
means the embedder is loaded once instead of once per slice.

CLI output now matches the whole-file pipeline's turns exactly:

    0.28-10.84  speaker 0     (truth  0.00-10.50  SPEAKER_00)
   11.64-15.88  speaker 1     (truth 10.50-15.90  SPEAKER_01)
   17.04-26.80  speaker 0     (truth 15.90-26.70  SPEAKER_00)
   26.52-31.52  speaker 1     (truth 26.70-31.50  SPEAKER_01)

The last row is the fix — it read speaker 0 before.

Scope note: this is the unified crispasr_run path. The legacy cli.cpp
whisper path still diarizes per slice, the same fallback situation the
pyannote cache has there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The upstream estimator does not work on real speaker embeddings.
Silhouette saturates and climbs monotonically to whatever ceiling it is
given: within-speaker cosine is only ~0.595 while cross-speaker is
~0.100, so splitting a real speaker keeps cutting the intra-cluster term
while the inter-cluster term barely moves. On samples/multispeaker.wav
with max_speakers=8 it reports 7-8 speakers where the truth is 2.

Replaced with the eigengap of the normalised Laplacian, the standard
estimator for spectral diarization: it reads cluster structure off the
spectrum instead of scoring partitions, so saturation cannot arise.

A naive eigengap does NOT work here, and the reason is worth recording:
the cosine affinity (cos+1)/2 is DENSE — around 0.5 even for unrelated
windows — so the graph is nearly complete, one eigenvalue dominates, and
the largest gap always falls at k=1. First implementation duly reported
one speaker for all five synthetic cases. Row-wise thresholding fixes it:
keep each row's strongest 15%, ATTENUATE the rest by 0.01 rather than
deleting them so the graph stays connected, then symmetrise with an
elementwise max.

  configuration                  synthetic (5 true-k)   real, max=8
  BIC + silhouette (upstream)    4/5 exact              7-8 speakers
  eigengap, no thresholding      0/5 (always k=1)       —
  eigengap + row thresholding    5/5 exact              2, correct turns

It also costs less — one eigendecomposition instead of a GMM sweep plus
max_k spectral runs. Winning on quality AND speed is what justifies
flipping the default; CRISPASR_DIARIZE_COUNT=bic keeps the upstream path
selectable, gated rather than deleted.

Because eigengap is robust to a loose bound, --diarize-max-speakers goes
back from the defensive 4 to 8, so a genuine 5-6 speaker meeting is
reachable again.

⚠ Caught by rebuilding: the existing test asserted the DEFAULT gets k=3
wrong, so flipping the default inverted its meaning — and it kept passing
against a stale binary until the test target was rebuilt explicitly
(HARD RULE #8's sibling trap). It is now split in two: one test pins the
default reaching the truth via eigengap, the other pins the gated BIC
path still needing FULL_K_SEARCH to get there.

Evidence limits, stated plainly: five synthetic configurations and one
31.5 s real clip. No DER on labelled audio yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sured

BLUEPRINT PARITY — the check that was missing. The upstream Python
pipeline (pip install diarize==0.1.2) was run on the same
samples/multispeaker.wav and compared against this port.

With the speaker count pinned to 2 on both sides the speaker assignment
is IDENTICAL and boundaries agree within ~1 s. Scored with the DER
harness (0.25 s collar, optimal 1:1 mapping), upstream as reference:

    missed        0.00 s
    false alarm   1.05 s
    confusion     0.00 s
    DER           3.93 %

Zero speaker confusion: wherever both assign a speaker, they agree. The
whole residual is false alarm and it is explained — upstream runs its own
Silero VAD and drops silence gaps, while this port tiles the caller's
speech regions contiguously. Different segmentation SOURCE, not a
diarization disagreement.

On automatic counting this port is BETTER than upstream: on the same clip
upstream emits 11 speakers across 25 segments at its default
max_speakers=20, this port emits 2. The gated CRISPASR_DIARIZE_COUNT=bic
path reproduces upstream's failure mode (7-8), which is what shows the
port is faithful — the improvement comes from the eigengap switch, not
from a divergence in the shared parts.

Session C ABI: crispasr_diarize_opts_abi gains foxnose fields, method
range 0..4. The struct is laid out BY HAND in bindings/go's cgo preamble,
so fields are append-only and the Go layout is updated in the same commit
— a missing field there means the C side reads past the end of a
Go-allocated struct. Go gets DiarizeSegmentsFoxNose + FoxNoseOpts;
DiarizeSegments keeps its old signature.

Registry: cstr/wespeaker-resnet34-lm-GGUF, so --diarize-embedder auto
--auto-download works end to end (verified from a cleared cache).
Uploaded files verified by SHA256, not size:
  wespeaker-resnet34-lm.gguf      2461a2b9…8d26
  wespeaker-resnet34-lm-f32.gguf  edd96a69…3499
Card carries license: cc-by-4.0 with attribution, as CC-BY requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I made eigengap the default on five synthetic blob configurations (5/5
exact vs BIC's 4/5) and one 31.5 s clip. Both were unrepresentative —
samples/multispeaker.wav is the same speech re-read by different
speakers, an unusually easy case — and a real benchmark contradicts it.

8 VoxConverse dev files, HUMAN labels, 0.25 s collar, optimal 1:1
mapping, pooled over 1109 s of scored reference speech:

    upstream Python diarize 0.1.2   miss 0.6  fa  4.7  conf  29.0   3.1 %
    this port, bic                  miss 0.0  fa 26.1  conf  32.7   5.3 %
    this port, eigengap             miss 0.0  fa 26.1  conf 101.3  11.4 %

Eigengap systematically UNDER-counts on real speech —

    reference   4  7  2  5  5  4  4  5
    eigengap    3  5  2  3  3  2  2  3
    bic         4  6  3  5  4  3  2  5

— and the confusion term triples. Default is back to bic;
CRISPASR_DIARIZE_COUNT=eigengap keeps it selectable, since it IS better on
well-separated data and costs less. The unit test now pins the default so
a synthetic win cannot quietly flip it again.

On the remaining 5.3 % vs 3.1 %: our false alarm is 26.1 s against 4.7 s
because the benchmark driver hands the pipeline WHOLE FILES as one speech
region while upstream runs Silero VAD first. Substituting upstream's false
alarm gives 3.4 %, i.e. parity within ~0.3 points. The real CLI path has no
such handicap — it takes the caller's ASR/VAD segments. Adding a VAD stage
to the benchmark driver is the obvious next step.

This is what the earlier "no DER on labelled audio" caveat was protecting
against, and it caught a wrong call. Synthetic known-answer tests are
necessary but they are not an accuracy benchmark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous benchmark handed our pipeline WHOLE FILES as one speech
region while upstream ran Silero VAD first, charging us 26.1 s of false
alarm the real CLI path never incurs (it takes the caller's ASR/VAD
segments). Re-run with the same Silero VAD and upstream's parameters
(threshold 0.45, min speech 200 ms, min silence 50 ms, pad 20 ms), over 8
VoxConverse dev files against HUMAN labels:

    upstream Python diarize 0.1.2   miss 0.6  fa 4.7  conf 29.0   3.07 %
    this port, bic + Silero VAD     miss 0.0  fa 9.0  conf 26.5   3.18 %
    this port, bic, no VAD          miss 0.0  fa 26.1 conf 32.7   5.27 %

0.11 points apart, and our speaker CONFUSION is lower (26.5 vs 29.0) —
the residual is false alarm, not diarization. Estimated speaker counts now
differ on one file of eight:

    reference   4 7 2 5 5 4 4 5
    this port   4 6 2 5 4 4 2 5
    upstream    4 7 2 5 4 4 2 5

This confirms the earlier arithmetic estimate of 3.4 % (actual 3.18 %) and
closes the acceptance question the port has carried since the start: the
port is faithful, and the gap was the measurement setup.

tools/der_score.py carries the scorer (the Python twin of src/core/der.h)
so the benchmark is reproducible for pipelines that are not CrispASR; the
VoxConverse recipe is in docs/foxnose-diarize/PLAN.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing.md

`tools/check-backend-wiring.py` reported PASS and every test binary I had
been running was green, but walking the checklist by hand found four
things neither covers:

* bindings/go/whisper.go cgo LDFLAGS were STALE. wespeaker is a new
  add_library reachable from crispasr-lib, so the hand-maintained -l list
  needed regenerating — this is the CI-enforced `cgo-ldflags-drift` check
  and it would have gone red. (The drift checker itself failed to run at
  first against a stale temp graphviz dir from an earlier port; clearing
  it surfaced the real answer.)
* tests/env-live-tests.sh had no CRISPASR_MODEL_WESPEAKER, so the live
  tests could not run from ctest at all.
* docs/environment-variables.md documented none of the new gates
  (CRISPASR_DIARIZE_COUNT / _FULL_K_SEARCH, CRISPASR_WESPEAKER_*).
* docs/diff-harness-coverage.md is generated by audit_diff_coverage.py and
  had not been regenerated since the wespeaker reference backend landed.

Also adds the README row for foxnose diarization, with its measured DER.

Verified after: 1213/1213 unit tests pass (the earlier "12 failed" was 12
NOT-RUN binaries I had never built, not failures); wespeaker live 11/11
and gigaam live 12/12 through ctest; LDFLAGS drift check clean; wiring
audit PASS.

wespeaker is deliberately NOT a --backend: it is a component consumed by
crispasr_diarize.cpp, exactly like pyannote-seg, so the 12-point backend
checklist applies only in part.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four commits, one of which matters to #324:

  00285218 cpu: build conv_2d/conv_3d im2col patches in vec_dot_type
           (fixes F16 conv_2d)
  6572b47f ci: hosted-runner CI + carried-patch guard on crispstrobe-ops
  6397bc5c test: fix heap overflow in test-quantize-fns scratch buffers
  14b4971d cuda: fix batched indexing in our k-quant GET_ROWS patch

00285218 removes the GGML_ASSERT(src1->type == GGML_TYPE_F32) that made
convert-wespeaker-to-gguf.py pin every 4-D conv kernel to F32. F16 conv
now runs and is numerically fine (cosine 0.99999724 vs the oracle, and
the GGUF drops 23.9 MB -> 13.3 MB), but it is 2.2x SLOWER on CPU
(297 vs 133 ms per 1.2 s window, same loop, back to back), so the F32
pin STAYS. The comment in the converter now says why it is a speed
choice rather than a crash workaround.

ggml/ci/check-crispasr-patches.sh reports 24 patches present.
1213/1213 unit tests pass against the new pointer.
…round

The previous commit's message said this had been written; it had not been.
Both the converter comment and the model card still described the F32 pin as
a way around GGML_ASSERT(src1->type == GGML_TYPE_F32), and pointed at a note
in wespeaker.cpp that does not exist. The fork's 00285218 removed that
assert, so the old reason is gone and the real one needs recording: F16 conv
kernels work and halve the file, they are just 2.2x slower on ggml's CPU conv
path (297 vs 133 ms per window, same loop, back to back, M1).
CrispStrobe and others added 30 commits August 6, 2026 10:33
AGENTS.md is the only agent-instruction file in this repo that reaches a clone:
CLAUDE.md is gitignored (per-checkout), and the development guide lives BESIDE
the repo and is not tracked anywhere. So the two documents that explain how to
work here were invisible to anything but the maintainer's own machine, and the
guide got read only when a human typed its path by hand.

Adds a READ-FIRST pointer phrased for both audiences: on a maintainer checkout
the sibling ../crispasr-crispembed-dev.md is there and must be read in full
(explicitly NOT summarised here — it wins any conflict); on an outside clone it
is absent, nothing is missing from the build, and the map below is enough.

The map is here rather than duplicated per-checkout so there is one copy to
drift. It also front-loads the two habits that keep costing time: PLAN/HISTORY
prose goes stale (audit the code, not the note), and CLI auto-detection proves
nothing about the bindings (#335).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ernel_im2col_flat on sync/upstream-v0.17 (89a2039d)

The v0.17 sync dropped the OCC variant; the successor covers batch-1 AND
the conv_2d_dw lowering (the class #23 missed), measured 2.3x on CrispEmbed
PP-OCRv6 rec / 1.6x layout_detect, byte-identical. CrispASR activation needs
a pin bump to >= 89a2039d plus fresh melotts/moonshine parity+timing runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-candidate probe line used printf "%.60s", which cuts at 60
BYTES. For Greek, Arabic or CJK that lands inside a multi-byte
character and puts a severed lead byte on stderr.

Not cosmetic, and not theoretical: it killed the republish kernel
outright. Python's subprocess decodes stderr as text, so the 14-way
probe raised UnicodeDecodeError on 0xce — the Greek candidate's lead
byte — and took the run down after its uploads had completed. Any
consumer reading our stderr as text hits the same thing, and only ever
for non-Latin languages, which is why local Latin testing never saw it.

utf8_prefix() walks back to a codepoint boundary. The test loops every
cut point of a Greek, an Arabic and a CJK string: naive truncation is
invalid UTF-8 at 47 of 107 offsets for the Greek fixture, so the guard
genuinely goes red on the old code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…14-probe claim

Two things, both found by finally RUNNING the converter end-to-end.

1. GGUFWriter(path, arch) already writes general.architecture, and the
   converter wrote it again. gguf-py versions that reject duplicates raise
   ValueError; older ones only warned and overwrote, which is how this
   survived — the converter had never been run on a strict version. It now
   completes: 2104 tensors, 4.14 GB, supported_languages=['en','ar'],
   max_clip_s=35, and the resulting model transcribes Arabic correctly and
   enforces its own whitelist.

2. Retracts the "accuracy degrades past ~4 candidates" claim I documented
   in 0b85808. Measured on the REAL 14-language base model (republished
   with its whitelist), the forced 14-way probe is CORRECT on both clips:
   jfk.wav -> en (228, p=0.169), an Arabic clip -> ar (292, p=0.254). The
   earlier "picks fr" came from forcing a 14-language list onto the
   TWO-language Arabic finetune, which translates when asked for a language
   it lacks; the real base model's fr probe code-switches instead ("Et so,
   my fellow Americans...", agreement 0.00, score 57) and loses.

   The <=4 ceiling stays, for the reason originally intended: cost. 14
   probes take 37 s on an M1 against ~1 s for whisper-tiny.

   The scoring soft spot is real and stays documented — a fluent
   translation CAN outscore repetitive truth — it is just not what the
   candidate count controls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…robe claim retracted

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… (cohere)

Also: forcing a fake capability list does not simulate having the
capability — it exercises a different failure mode. That is what made my
first 14-candidate probe measurement wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…1 im2col occupancy win the v0.17 sync dropped

The sync removed CRISPASR_METAL_IM2COL_OCC (dispatch reworked upstream),
silently un-fixing the melotts/piper P0-hifigan row. The re-derivation
(kernel_im2col_flat, authored on the CrispEmbed PP-OCR profile where it is
2.3x) covers the old batch-1 case via N*KH*KW<128 plus the conv_2d_dw
lowering (IC==1); CRISPASR_METAL_IM2COL_FLAT=0 restores the standard kernel.

Validated on M1 (interleaved pairs): melotts hifigan_decode 2.02 s -> 1.10 s
(1.85x), round-trip clean (WAV byte-compare is invalid for melotts — the
VITS flow is stochastic, legacy-vs-legacy already differs); moonshine
transcript byte-identical, RTF neutral+; paraformer transcript
byte-identical, its 2 im2col nodes 8.5-9.1 -> 0.4-1.0 ms/node (wall pairs
were load-noise, mechanism checked per-op). 1473/1473 unit tests.
Evidence: PERFORMANCE.md top entry (2026-08-06).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d pin (melotts 1.85x, moonshine/paraformer byte-identical)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MetadataDetails gained sub_type in newer gguf-py; the version installed
here is (type, value, description) and raised TypeError, so the script
could not run at all. Pass the kwarg only when the field exists —
omitting it is safe, add_key_value infers the element type from the
first element.

Verified on a synthetic glm-asr GGUF: merges land as ARRAY/STRING with
both the pair-form and string-form inputs normalised, and arch, vocab
and tensors pass through unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cohere Transcribe transcribes NON-SPEECH as text, and our chunker splits
long audio at RMS minima without ever DROPPING the quiet parts. Measured
on the Arabic q4_k-imatrix build:

  10 s of pure digital silence -> "And I'm going to go ahead and do that."
  jfk.wav + 20 s of silence    -> that same sentence appended to an
                                  otherwise perfect transcript

With VAD both disappear. (Low-level noise and a 440 Hz tone produce
nothing, so it is silence specifically.)

A/B'd on real speech before flipping, and VAD wins there too: on a 60 s
FLEURS clip the un-VAD'd run cut mid-sentence, garbled a clause ("how
acidic, basic, alkaline the cabbage juice is" for "...the chemical is")
and DROPPED an entire sentence that the VAD run recovers. So this is a
content-recovery win, not just a silence guard.

Uses the existing long-audio safeguard: arms only above 30 s and only
when no --vad / --vad-model / --chunk-seconds was given. Verified: 31 s
input auto-enables and comes back clean, 11 s does not arm, and an
explicit --chunk-seconds still wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The encoder output and cross-attention KV depend only on the AUDIO —
the language enters solely through the decoder prompt — and encode is
87% of a pass (881 ms vs 113 ms decode, M1 q4_k-imatrix on 11 s
jfk.wav). So probing N languages over one clip was paying N encodes for
one clip's worth of information.

Two `{` become `if (!reuse_enc) {` — no re-indentation, and the cross-KV
free+realloc lives INSIDE the second block, so skipping it keeps the
previous allocation live, which is exactly what gets reused.

A/B on 14 candidates, back-to-back on a quiet box, and repeated in
reverse order to rule out cold-start bias:

  OLD  12 s, 12 s      NEW  5 s, 4 s      output byte-IDENTICAL

Normal transcription is untouched (the flag defaults false): short,
long-chunked and Arabic transcripts all match their pre-change
baselines. CRISPASR_COHERE_PROBE_REUSE_ENC=0 restores one encode per
candidate.

The invariant — reuse only across calls over the SAME samples — is
documented on the context field, and cohere_detect_language is the only
setter and clears it in the same function.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e, merges tool

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Handed a span with no signal, this model does not return nothing — it
invents. 10 s of zeros decodes to "And I'm going to go ahead and do
that."; and since the chunk loop re-enters transcribe_ex per chunk, a
long file whose trailing 30 s window is silent gets that sentence
appended to an otherwise perfect transcript.

prefers_vad (3dea0fe) hid this above 30 s, but VAD only auto-arms on
long audio and costs a model download, so a plain 10 s silent file still
produced a sentence. This gates it directly: no signal in, empty
transcript out, at zero cost and with no extra model.

The threshold is deliberately near-exact. 1e-5 sits BELOW one int16 LSB
(3.05e-5), so a single non-zero sample disables the gate. Measured
headroom against real audio: the quietest speech to hand (a FLEURS clip)
peaks at 0.038 — ~3800x the threshold — and low-level noise at ~0.0018
does not provoke the model anyway. So the gate covers exactly the
observed failure and declines to guess about anything else.

Verified: 10 s silence -> empty; jfk, Arabic and the -28 dBFS FLEURS
clip all unchanged; the 31 s chunked case is clean even with VAD
bypassed via --chunk-seconds; CRISPASR_COHERE_SILENCE_GATE=0 restores
the old behaviour on both. Predicate lives in audio_chunking.h with
hermetic tests covering both failure directions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…not short clips

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The whitelist and probe-LID docs I wrote in 0b85808 only ever existed
in hf_readmes/ — the cards on HuggingFace still said nothing about a
restriction that now actively rewrites a user's -l. Uploaded to both
repos.

Uploading the in-repo copy verbatim would have DELETED the "Provenance
and EU AI Act Art. 53 note" section: it exists on the published cards
and not in hf_readmes/, so the repo copy had drifted BEHIND HF. The
upload was rebased on the live card and adds only the new sections —
verified purely additive (36 and 13 lines added, 0 removed, frontmatter
byte-identical), and both live cards re-checked afterwards for the AI
Act section and license: apache-2.0.

This back-ports that section so hf_readmes/ stops trailing. NOT synced:
the published cards still carry the `whisper.cpp` tag and a plain repo
URL where hf_readmes/ has `crispasr` and a `/tree/ggml` link. One of
those is a deliberate rebrand and the other looks like a stale branch
link; deciding that is not part of this change, so both sides keep what
they had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…once/compute-many fix

The fork commit makes repeat computes of one sched allocation safe
(state-flagged src-rewire log; CrispEmbed O6 replay crash root cause) and
is behavior-neutral for CrispASR's reset-per-step engines — verified:
1477/1477 unit tests, moonshine + paraformer transcripts byte-identical
to the 89a2039d baselines, melotts TTS->ASR roundtrip reproduces the text
exactly. Unblocks any future persistent-graph decode path here (the
CrispEmbed PERFORMANCE.md 'sched alloc-once' entry has the contract).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ps, metal im2col batch-1 occupancy, conv_1d batch layout fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reported by toki1703 in a HuggingFace PR (cstr/cohere-transcribe-03-2026-GGUF
discussion #2) on 2026-05-21, still open. Every step was wrong, and I
verified each against the repo before changing it:

  git clone -b ggml ...   -> that branch has 0 remote heads
  make -j$(nproc) cohere-main -> no Makefile build; no such target
  ./bin/cohere-main       -> no such binary; it is `crispasr --backend cohere`

Plus one the report predates: ggml is a SUBMODULE now, so a plain clone
leaves ggml/CMakeLists.txt missing and cmake refuses to configure — the
build system itself prints "re-clone with --recursive".

Verified by running the corrected steps from a clean clone: recursive
clone -> cmake configure -> build --target crispasr-cli -> ./bin/crispasr
exists and parses `--backend cohere -m ... -f ... -l en`.

Also fixes the stale /tree/ggml link in the prose, and the comparison
table's `cohere-main` reference.

I published an addition to this card yesterday and did not notice any of
this, because I only diffed my own insertions against the live copy and
never read what I was adding to. The Arabic card was already correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… surfaces

SubtitleEdit's OmniVoice language menu did nothing. Three independent breaks:

1. The CLI adapter's synthesize() applied only tts_num_steps per call;
   omnivoice_set_language ran ONLY in init(). The server owns one backend
   instance per session, so after the first line the menu could never change
   anything — even though crispasr_server.cpp has parsed language/target_lang
   into rp.language since #249/#304.
2. The session C-ABI's omnivoice arm was a bare omnivoice_synthesize(), so
   set_target_language never reached it and bindings/Flutter/Android had no
   language knob by any route. #329's cosyvoice3 bug, one backend over.
3. The runtime dropped the string VERBATIM into <|lang_start|>…<|lang_end|>.
   The blueprint's _resolve_language() is ID-passthrough → lowercase-name
   lookup → None; we did none of it, so 'de-DE' or a typo conditioned the model
   on tokens it never saw in that slot while looking like it worked.

core/omnivoice_lang.h mirrors the blueprint resolver over a generated 646-id
table (tools/gen-omnivoice-lang-map.py <- upstream lang_map.py). The runtime
resolves centrally in omnivoice_set_language so no surface can skip it, and
warns with a did-you-mean before falling back to language-agnostic.

Verified on CODES, not WAVs — output is watermarked, so cmp on audio measures
the watermark. CRISPASR_OMNIVOICE_DUMP_CODES + --no-spoken-disclaimer, English
jfk.wav reference -> German target: '-l de' vs none DIFFERENT; '-l German' vs
'-l de' IDENTICAL; '-l de-DE' vs none IDENTICAL; session vs CLI IDENTICAL;
server vs CLI IDENTICAL across three sequential requests on one process.

NEGATIVE RESULT, recorded in docs/omnivoice/PLAN.md: this does NOT demonstrably
fix the reported accent. whisper LID over 3 German sentences moved only on the
first (0.927->0.998); sentences 2-3 were already 0.999 untagged and the tag
moved neither. LID is accent-robust by design, so it is the wrong metric.
OmniVoice also has no cross-lingual drop-ref path — create_voice_clone_prompt
requires ref_text and _combine_text positions it before the reference audio —
so #329's transcript-drop fix does not transfer.

Adjacent fix: /v1/audio/speech advertises a per-request seed that the omnivoice
adapter dropped, so re-rendering a line was not reproducible. Applied when
non-zero; --seed 999 differs from the default and repeats byte-identically.

Still open SE-side: OmniVoiceCrispAsr.Speak() accepts the language and never
sends it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ually run it

The generator fetched lang_map.py from GitHub, so `--check` needed the network
and was left out of CI — which meant nothing caught a hand-edit of
src/core/omnivoice_lang_table.h, and the ids in it are what the model is
conditioned on (#13273).

Vendor the source instead: third_party/omnivoice/lang_map.py is a byte-identical
copy of upstream (blob ffcda10, commit c8c0a625), so --check renders from it
with no network and is now a hard gate in lint.yml. Verified it goes red — a
one-id hand-edit of the header exits 1.

Upstream drift is a separate, best-effort question and gets its own advisory
step: --check-upstream compares git blob hashes via the GitHub API, prints a
NOTE and exits 0. It must not auto-fail, because taking a new revision is a
judgement call — a NEW language is an addition we want, but a CHANGED id for an
existing name silently alters what every caller of that name synthesises.
--update-vendored does the refetch when that call is made.

Apache-2.0 notice added for the vendored file; its upstream header is retained.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SubtitleEdit's OmniVoice language menu is still not wired to its request
payload, so every dubbed line arrives language-agnostic no matter what the user
picks. Rather than leave the fix blocked on someone else's release, omnivoice
now detects the language from the text it is about to speak when — and only
when — no language was requested. CRISPASR_OMNIVOICE_AUTO_LANG=0 restores the
old behaviour.

Guessing is normally the wrong instinct, so the justification is a measurement
of the HARM side, not of detection accuracy: German text deliberately mis-tagged
'-l en' gives a word-perfect ASR round-trip and whisper LID 'de' at 0.984 — the
same band as the correct tag (0.947) and no tag (0.998), i.e. no ordering at
all. A bad guess costs nothing detectable; the upside is the one upstream
documents ("performance is slightly better if you specify the language"). If a
later measurement shows a wrong tag DOES degrade output, flip this back to
opt-in — that asymmetry is the whole argument.

Verified on codes with --no-spoken-disclaimer: no -l at all == explicit -l de
IDENTICAL; AUTO_LANG=0 == the old untagged codes IDENTICAL; explicit -l en on
German text == the pre-existing -l en result IDENTICAL (the guess never
overrides); and an SE-shaped POST of {input, response_format} with no language
field == -l de IDENTICAL.

Two load-bearing details, both guarded:
- Detect over the TARGET text, never combined_text — the latter carries the
  reference transcript, so an English reference clip would drag every German
  subtitle's guess to English, silently and only when cloning.
- Per call into a local, never written back to ctx->language — the server
  reuses one context, so a sticky guess would leak line N onto line N+1, the
  same per-call-vs-init bug this change set exists to fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The style prefix is where the language and instruct conditioning actually
lives, and a BPE difference there is invisible in every downstream metric --
the graph still computes and the audio still sounds like speech. Printing the
ids makes prompt-token parity against the HF tokenizer a one-command check
(dev-guide step 0), which is how the #13273 language path was confirmed
byte-identical to the blueprint for de/en/arb/zh, None, and the <|denoise|>
clone variant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…heet + real patch; prose stays human-authored
…sibling

Not from a report. Going back to diff our runtime against the blueprint properly
turned up _resolve_instruct sitting ten lines below _resolve_language, doing the
same job for the same prompt slot, mirrored no better.

The instruct is a CLOSED 48-item vocabulary (6 mutually-exclusive categories,
each item with an EN/ZH counterpart). Upstream lowercases it, repairs
half/full-width separators, unifies it to one language and RAISES on anything
else. We stored the raw string and dropped it into <|instruct_start|>:

    'Male, British Accent'  -> [151672, 36421, 11,  7855, 81809, 151673]
    'male, british accent'  -> [151672, 36476, 11, 93927, 29100, 151673]

Not one shared token id, for what a user considers the same request. It also
carried the SAME per-call bug as language — set only in init(), so the server's
per-request "instructions" field was dead after the first line — in the very
function just edited to fix that for language.

Mirrors upstream by rejecting rather than degrading (the CLI exits non-zero, the
server 400s with the offending item and a did-you-mean), because a voice-design
request that silently does nothing is the failure being fixed. Two-phase on
purpose: parse() is text-independent and runs at set time; render() needs the
text — a dialect forces Chinese, an accent forces English, otherwise it follows
whether the TARGET text is Chinese — so it runs per synthesis. Baking the
rendered string at set time would freeze one line's EN/ZH choice onto every
later line on a reused context.

Verified against the blueprint resolver AND the HF tokenizer: 'Male, British
Accent' on English text and 'Male, Elderly' on Chinese text both produce style
ids byte-identical to AutoTokenizer; 'britsh accent' → CLI rc=13 / server 400;
'male, female' → 400; three different instructs on one server process each
applied (codes DIFFERENT per request).

Along the way the language path itself is now PROVEN rather than argued: style
ids byte-identical for de/en/arb/zh, None, and the <|denoise|> clone variant.
Qwen2 has no BOS so the blueprint's add_special_tokens asymmetry is a no-op; the
uncond CFG arm is target-audio-only in both; normalize_text defaults off and the
official CLI never passes it. Correction: treating "auto" as cleared is not an
addition on top of the blueprint — demo.py:186 does exactly that.

The suggestion string is the one deliberate non-parity (LCS ratio vs difflib's
Ratcliff/Obershelp, same 0.6 cutoff); it changes no model input.

voice_design.py vendored byte-identically alongside lang_map.py so the new CI
gate is hermetic too; verified it goes red on a tampered table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…CJK range

Two gaps from my own audit of the #13273 work.

1. The resolvers had unit tests; the string they FEED did not. Prompt-token
   parity was verified against the real HF tokenizer by hand — 1.2 GB model,
   debug print, eyeball — which is exactly the check that gets done once and
   then rots, and is how the instruct defect survived the first review. Style
   assembly moves to core/omnivoice_prompt.h (weight-free) so the exact string
   is unit-testable, and every string asserted was verified byte-for-byte
   against AutoTokenizer, with the ids recorded in the test file. Verified the
   guard goes red: injecting one space into a tag fails 3 of 5 cases.

2. text_is_zh() sniffed the UTF-8 lead byte (0xE4..0xE9) where Python's _ZH_RE
   is exactly [U+4E00, U+9FFF]. Those look equivalent and are not — the shortcut
   over-matches U+4000..U+4DFF by 3584 codepoints (CJK Ext-A, Yijing hexagrams).
   That window decides whether an instruct renders "male, elderly" or
   "男,老年", so it now decodes the codepoint properly. Tested at both range
   edges, inside the over-match window, and across kana/hangul/emoji (the
   4-byte case the decoder must step over, not into).

Also asserts the auto-language fallback is env-gated and defaults ON — an
accidental flip to false would be invisible, since output would just revert to
language-agnostic.

1510/1510 unit tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…checks

Closes the gaps my own coverage audit turned up.

CORRECTION: I reported "there is no session instruct setter at all".
crispasr_session_set_instruct has existed all along, dispatching to qwen3-tts
and parler — omnivoice fell through to `return -3`, i.e. "this backend has no
instruct contract". So it was the same wiring bug a THIRD time, not a missing
feature. Now dispatched, which makes voice design reachable from every binding;
the Python set_instruct needed no change beyond documenting that omnivoice takes
a closed vocabulary rather than prose. Guarded by a source test scoped to the
function body, watched red by unwiring the arm.

Two things had been verified by hand only and are now runnable gates:

- tests/test-omnivoice-style-tokens.sh — prompt-token parity against the real
  vocabulary. The unit tests pin the style STRING; this pins what it tokenizes
  to, which is what the model consumes and the one link no hermetic test sees.
  9 cases, ids from the reference Qwen2Tokenizer. All pass; verified red (one
  wrong id → rc=1, checked without a pipe laundering the status).
- tests/test-omnivoice-surface-parity.sh — CLI / server / session must agree.
  Source guards catch a MISSING call site; only this catches one present and
  wrong. Compares CODES, never the watermarked WAV, and every check includes an
  arm expected to be IDENTICAL. 10 arms pass including the session one.

Writing the parity test caught a stale assumption of mine: with auto-detect
shipped, "no language" on German text is no longer agnostic — it is `de`, so
those arms are correctly identical. The discriminators now use two EXPLICIT
languages, so the guesser cannot mask a dead knob by making every arm German;
the agnostic baseline runs with AUTO_LANG=0; and the auto-detect contract is
pinned separately on the SubtitleEdit-shaped request.

Both SKIP cleanly without a model; env registered in tests/env-live-tests.sh.
1511/1511 unit tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…can name the side that moved

The live style-token test pinned ids from a tokenizer run with no record
of WHICH tokenizer — a mismatch could not say whether our side or
upstream drifted, and closing that honestly seemed to mean vendoring the
7 MB tokenizer.json. Cheaper close: record the provenance instead.

- Test header now pins k2-fsa/OmniVoice rev c5fdb5cc + tokenizer.json
  sha256 408f669b, with the one-command disambiguation (same sha -> our
  side moved, fix us; new sha -> re-derive pins AND regenerate the GGUF
  vocabulary, update both).
- All 9 sequences re-derived byte-identical today from that exact file
  (tokenizers 0.23.1, independent of our code); upstream main unchanged
  since 2026-07-03, so the pins' lineage is a single revision.
- Both live gates re-run green on a rebuilt binary (style-tokens 9/9,
  surface-parity all arms; session arm SKIP, binding not built here).
- PLAN de-staled: the SE-side payload gap is no longer blocking since
  the auto-lang fallback (cf93079) — the old block still said the menu
  stays decoration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he gate able to go red

`crispasr-diff omnivoice` was a stub — loaded the model, printed the ref
token count, compared nothing, exited 0 — while the real per-stage
comparison (omnivoice_encode_diff) hid behind an env var in the CLI
adapter. And run_encode_diff returned 0 unconditionally: with the
corrupt tokenizer it printed acoustic_enc FAIL and FULL wav->codes 15.0%
and still exited clean.

- diff-main omnivoice branch now resolves the audio tokenizer
  (CRISPASR_OMNIVOICE_TOKENIZER_GGUF, else next-to-model candidates
  mirroring the CLI adapter) and runs the encode diff as a counted
  harness stage.
- run_encode_diff counts main-chain stage failures and returns nonzero;
  RVQ gated at >=99.5% exact (measured 99.9%), FULL wav->codes at >=95%
  (measured 99.0%; residual is the documented resampler-vs-torchaudio
  Hann-sinc gap, corrupt-tokenizer mode sits at 15%).

Verified red-first with the quarantined corrupt tokenizer GGUF: 2 stages
FAILED, crispasr-diff rc=6, CLI env path rc=1. Green run: all stages
cos_min=1.000000, RVQ 2197/2200, FULL 2178/2200, rc=0. Hermetic
omnivoice tests still pass.

Also refreshed docs/diff-harness-coverage.md (madlad row, dep drift).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants