Skip to content

feat/moonshine streaming breeze - #3

Merged
sleep3r merged 4 commits into
mainfrom
feat/moonshine-streaming-breeze
Sep 1, 2026
Merged

feat/moonshine streaming breeze#3
sleep3r merged 4 commits into
mainfrom
feat/moonshine-streaming-breeze

Conversation

@sleep3r

@sleep3r sleep3r commented Sep 1, 2026

Copy link
Copy Markdown
Owner
  • feat: Moonshine V2 streaming models + Breeze ASR
  • feat: transcribe-cpp engine + GigaAM RNN-T and other GGUF models
  • chore: release v0.8.0
  • fix: stop NME-SC reading the speaker count off the neighbour graph

Adds the four transcription models Handy hosts that we did not, all now
mirrored into our own bucket (sha256-verified against Handy's published
values):

  breeze-asr-q5_k.bin                  Taiwanese Mandarin, code-switching
  moonshine-{tiny,small,medium}-streaming-en   Moonshine V2

Breeze ASR is a whisper-large-v2 fine-tune, so it loads on the existing
Whisper engine unchanged. Moonshine V2 needs a new EngineType because it
is a different transcribe-rs type (StreamingModel, not MoonshineModel);
we drive it one-shot, exactly as Handy does.

Guard against an upstream transcribe-rs 0.3.11 crash while wiring it up.
Both Moonshine engines reject certain input lengths instead of padding
internally: moonshine_streaming feeds samples.chunks(1280) and its first
convolution aborts on a final chunk under 80 samples, so any recording
whose length mod 1280 lands in 1..=79 fails outright. Our tail chunk is
arbitrary-length and propagates with `?`, so a fully transcribed
recording would be thrown away. Verified against the real model: lengths
with remainder 1/7/40/79 error, 80+ succeed, padding clears all of them.
The same guard fixes a pre-existing batch-Moonshine crash under 1600
samples.

Also:
- snapshot audio duration before padding, or the fallback segment
  stretches by up to 80 ms and drags diarization word alignment with it
- drop the outgoing engine before loading the next one; a
  large -> breeze-asr switch transiently held both (~2.1 GB resident)
- correct size_mb on 7 models; the UI renders these as MiB and several
  were off by up to 51 MB
- list every shipped model in MODEL_ORDER, so the half that was sorting
  alphabetically stops looking arbitrary

Track scripts/ tooling in git (it was entirely gitignored) and replace
the one-shot migrate script with scripts/sync_models.sh, which reads
credentials from .env, verifies sha256 before upload, and re-checks each
object over the public URL. Documented in scripts/MODELS.md, including
the Selectel quirk where a denied GetBucketLocation makes mc sign with an
empty region and every request returns AccessDenied.
Replaces the whisper-only backend with transcribe-cpp, which reads the
architecture out of a GGML/GGUF file and covers 18 model families. That
unlocks models transcribe-rs structurally cannot run — notably GigaAM's
RNN-T decoder heads, since transcribe-rs implements GigaAM as CTC-only.

New models (single-file GGUF, mirrored to our bucket, all permissive
licences — MIT / Apache-2.0 / CC-BY-4.0):

  gigaam-v3-e2e-rnnt   Russian, most accurate, punctuation + casing
  gigaam-v3-rnnt       Russian, lowercase, unpunctuated
  gigaam-v3-ctc        Russian, fastest
  parakeet-unified-en  English, 0.2 s for 6 s of audio
  qwen3-asr-0.6b       30 languages with language detection
  canary-1b-flash      en/de/es/fr with translation

Verified end to end on synthesized speech rather than assumed: GigaAM
e2e-rnnt returns "Привет. Это проверка распознавания русской речи..."
in 0.3 s for 6.9 s of audio, and Qwen3-ASR handles both Russian and
English with correct language detection.

MUST-KNOW: transcribe-rs's "whisper-cpp" feature is removed in this same
commit and must never come back alongside transcribe-cpp. Both vendor
their own ggml (0.9.5 vs 0.20.2) and export the same symbols; their cargo
`links` keys differ, so cargo accepts both, the C++ compiles, the binary
links, and the app launches and enumerates devices — but only one ggml
survives, and ~46 of ~100 GGML_OP ordinals then mean different operations
because GGML_OP_COL2IM_1D is inserted mid-enum. The failure is completely
silent and shows up as garbage transcripts. The new `no-duplicate-ggml`
CI job is the only automated defence.

Existing users keep their downloaded models: transcribe-cpp loads our
shipped ggml .bin files unchanged, confirmed by loading and transcribing
with ggml-small.bin before making the swap.

Fixes found while integrating:
- chunk size now follows the engine. GigaAM reports max_audio_ms 25000,
  under our hardcoded 30 s chunk, so every recording longer than one
  chunk would have failed. The limit is read at load time, so a future
  model with a tighter cap needs no code change.
- GGML_NATIVE=OFF via the workspace-root .cargo/config.toml. Apple clang
  rejects `-mcpu=native` on arm64 so the build failed outright, and where
  it does work it tunes the binary to the build machine's CPU — wrong for
  a shipped artifact. Root-level so it applies from any cargo invocation
  directory.
- CI looked for bundles under src-tauri/target, but the [workspace] table
  puts them at ./target. The AppImage step that strips libwayland-client.so
  has therefore been silently no-opping, shipping unfixed AppImages, and
  the test workflows uploaded empty artifacts.
- rename "GigaAM v3" to "GigaAM v3 E2E CTC"; with four variants listed the
  bare name no longer identifies which decoder head it is.

Whisper also gains word-level timestamps, which whisper-rs could not
produce at all, so diarization word alignment improves for free.
Three shipped tests fail on a clean tree, and diarization is on by
default, so this is live: a solo dictation of 10-30 speech chunks (~40 s
to 2 min) is reported as roughly four speakers. Measured on 192-dim
Gaussian embeddings at the shipped max_speakers=6, the single-speaker
false-split rate was 86.8% overall and 100% at n=8..20.

Cause: the p sweep starts at 1. At p <= 2 each speaker's pruned kNN graph
is a path — every segment keeps only its one or two nearest neighbours,
so one person's segments end up chained rather than mutually connected.
A path's normalized Laplacian carries a large *internal* spectral gap
(0.293 -> 1.0 for a 5-node path) that lands inside the 1..=kmax eigengap
window and outranks the gap that actually separates speakers, so k is
read off the shape of the neighbour graph instead of the speakers. For
the two-speaker fixture the eigenvalues at p=1 are
[0, 0, .2929, .2929, 1, 1, 1.7071, 1.7071, 2, 2]: the true gap sits at
i=2 (0.2929) but the path's interior gap at i=4 (0.7071) wins.

Nothing else rejects such a p. The NME numerator p/n grows x4 over
p=1..4 while the gap grows only x1.77, so the argmin is pinned to the
most degenerate p on offer.

Fix: floor the sweep at p >= 3, the smallest p at which no node keeps
degree <= 2 and therefore no cluster can still be a path. This is not a
departure from the reference — NeMo's p counts the self-loop it fills
into the diagonal, so their p equals our p + 1, and our p=1 built a graph
strictly more degenerate than the reference ever constructs.

Measured against the unpatched code:

  single speaker, n=4..50, 25 seeds x 3 spreads   99/750  -> 743/750
  false-split rate                                  86.8% ->    0.9%
  balanced k in {1,2,3,4,6}, n=20                   11/45 ->   45/45
  error direction                        26 over / 0 under -> 0 / 0

Behaviourally a no-op at n >= 60 and cost-neutral (it removes two of
p_max eigendecompositions); all the value is in recordings under two
minutes. Timings at n=200/500/2000 are unchanged.

Two larger candidates were measured and rejected. A connectivity guard
was vacuous on exactly the graphs it targeted — an articulation-point
test passes a perfect matching, so it admitted p=1 on paired chunks in
40/40 seeds and returned results bit-identical to the unfixed code.
Switching to an unnormalized Laplacian produced k = 1 + (number of
outlier chunks), making it worse than the shipped code on corpora with
realistic noise.

The three failing tests now pass with their fixtures unchanged. Adds two
regression tests, both verified to fail without the floor: one pins the
degenerate-p mechanism directly (asserting the NME ratio really does
prefer p=1, so only the sweep bound keeps it out), the other checks that
one speaker stays one speaker across n=5..30 on noisy 192-dim embeddings
built from a seeded xorshift, so no rand crate and no clock.

Also wires cargo test into CI, which was running no Rust tests at all.
It needs default features — `--no-default-features` drops diarization
and silently skips exactly these tests.

Not fixed, and still open: the eigengap window can still reach into the
spectral bulk (the floor masks it rather than repairing it), a
signal-free p is still made maximally attractive by gap.max(1e-6), and a
single NaN embedding still collapses a whole recording to one speaker
because f32::max turns a NaN distance into similarity 1.0.
@sleep3r
sleep3r merged commit b3d4c3c into main Sep 1, 2026
3 checks passed
@sleep3r
sleep3r deleted the feat/moonshine-streaming-breeze branch September 1, 2026 11:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant