voice_audio_multi/tests/whistle_asr/VALIDATION.md
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

586 lines
36 KiB
Markdown

# Whistle ASR validation report
## Scope
- Move Whistle sources and headers into the community-model directories and namespace.
- Preserve the public `whistle_asr` family, schema-v1 specification, and spec-backed loader.
- Add community catalog entries, setup instructions, limitations, and source-specific licensing records.
- Keep model packages empty. No weights, extracted assets, logs, or recordings ship in this change.
Affected areas are `src/community_models/whistle_asr/`,
`include/engine/community_models/whistle_asr/`, `tools/community_models/whistle_asr/`,
`tests/whistle_asr/`, `model_specs/whistle_asr.json`, loader registration,
`CMakeLists.txt`, `README.md`, and ASR/community/licensing documentation.
Preparation follows [CONTRIBUTING.md](../../CONTRIBUTING.md), the
[community guidance](../../CONTRIBUTING.md#new-model-prs), and
[issue #54](https://github.com/0xShug0/audio.cpp/issues/54).
## Model setup and build
Use the published FP32 checkpoint and `.cact` assets at model revision
`b358ddadd89b7a713b5aa131f23032d3cca1b251`. Put `whistle.safetensors`,
`config.json`, and `LICENSE` in a dedicated source directory. Keep `.cact` outside it.
The converter verifies the pinned input hashes, extracts four assets, and embeds
those assets, configuration, license, and the current model specification.
The following exact PowerShell commands ran from the repository root unless noted.
The existing `build/whistle` configuration used Ninja, MSVC Release, CPU AVX2,
`GGML_NATIVE=OFF`, OpenMP, deployment mode, custom model set `whistle_asr`,
and `ENGINE_BUILD_TESTS=ON`. This configuration reused existing dependencies.
```powershell
python tools/check_loader_catalog_sync.py --self-test
python tools/check_loader_catalog_sync.py
& 'C:/Program Files/Microsoft Visual Studio/18/Enterprise/Common7/Tools/Launch-VsDevShell.ps1' -Arch amd64 -HostArch amd64 -SkipAutomaticLocation
cmake --build build/whistle --target audiocpp_cli audiocpp_gguf whistle_frontend_test whistle_assets_test --parallel 16
New-Item -ItemType Directory -Force build/whistle/pr-prep/source,build/whistle/pr-prep/model,build/whistle/pr-prep/standalone
Copy-Item -LiteralPath C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/source/whistle.safetensors,C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/source/config.json,C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/source/LICENSE -Destination build/whistle/pr-prep/source
python tools/community_models/whistle_asr/convert.py --source build/whistle/pr-prep/source --cact C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/whistle.cact --converter build/whistle/bin/audiocpp_gguf.exe --output build/whistle/pr-prep/model/whistle-f32.gguf
build/whistle/bin/audiocpp_gguf.exe --inspect build/whistle/pr-prep/model/whistle-f32.gguf
cmake -S . -B build/whistle -DAUDIOCPP_WHISTLE_TEST_MODEL=C:/Users/adityasharma/Projects/audio.cpp/build/whistle/pr-prep/model/whistle-f32.gguf
cmake --build build/whistle --target audiocpp_cli audiocpp_gguf whistle_frontend_test whistle_assets_test --parallel 16
ctest --test-dir build/whistle -R whistle_ --output-on-failure
build/whistle/bin/audiocpp_cli.exe --task asr --family whistle_asr --backend cpu --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --language en --metrics --log
build/whistle/bin/audiocpp_cli.exe --task asr --family whistle_asr --backend cpu --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/german_16k.wav --language de --metrics --log
```
## Observed validation
Measured on 2026-10-06, local Pacific time.
- Catalog self-test passed both tests. Catalog synchronization passed with 115 loaders and 113 specs.
Existing unrelated Safetensors package warnings remained; no unrelated fixes were made.
- Both builds succeeded. Reconfiguration initially ran without the MSVC environment and failed with
`LINK : fatal error LNK1104: cannot open file 'kernel32.lib'`. Repeating after
`Launch-VsDevShell.ps1` succeeded. Existing dependency/compiler warnings remained.
- Fresh conversion succeeded. Inspection reported 116 tensors, six embedded sidecars,
an embedded model specification, and `model_spec_family=whistle_asr`.
- CTest passed `whistle_frontend_test`, `whistle_asr_integration`, and
`whistle_asr_full_integration`, 3/3 in 3.59 seconds, using the freshly converted GGUF.
Frontend vectors are independent feature checks, not full-model FP32 reference parity.
- Integration exercises decoder reuse, quiet speech, constant-RMS speech, digital silence,
the 30-second limit, and non-finite input rejection. Decode exhaustion is inspected in code
and explicitly throws; it was not forced in an executed regression test.
- Actual CLI ASR passed with forced English and German. A separate same-session CLI batch
detected English twice and produced identical transcripts.
- CLI digital silence produced `text_output=`. A 480001-sample, 16 kHz mono recording failed
with `Whistle audio exceeds the 30-second input limit`.
Streaming failed with `Whistle currently supports offline ASR only`.
Exact CLI limit commands used the ignored generated input script:
```powershell
python build/whistle/pr-prep/create-inputs.py
build/whistle/bin/audiocpp_cli.exe --task asr --family whistle_asr --model build/whistle/pr-prep/model/whistle-f32.gguf --audio build/whistle/pr-prep/silence.wav --threads 4
build/whistle/bin/audiocpp_cli.exe --task asr --family whistle_asr --model build/whistle/pr-prep/model/whistle-f32.gguf --audio build/whistle/pr-prep/over-limit.wav --threads 4
build/whistle/bin/audiocpp_cli.exe --task asr --mode streaming --family whistle_asr --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --threads 4
```
The combined command invocation exited 1 after the expected streaming rejection.
Individual exit codes were not captured. Successful generation/silence and failed duration/streaming
are established by the generated inputs and actual CLI output above.
The standalone model directory contained only `whistle-f32.gguf` (220831648 bytes).
With a fresh repository-local `TEMP` cache, CLI reconstruction extracted `config.json`,
`LICENSE`, `tokenizer.blob`, `mel_filterbank_80.f32`, `hadamard_perm1.f32`, and
`hadamard_perm2.f32`. Extracted files matched the conversion source hashes.
CLI recognition also succeeded from `C:/Windows`, without adjacent sidecars or a working-directory
`model_specs` tree. Source inspection of `find_contract_spec_path()` confirms that the embedded
v1 contract takes precedence over workspace and builtin fallback.
An additional metadata check measured the new community documentation path inside the GGUF,
no stale core documentation path, and matching hashes for all six extracted sidecars.
```powershell
python build/whistle/pr-prep/check_embedded_assets.py | Tee-Object build/whistle/pr-prep/embedded-assets-check.log
```
This exited successfully with seven `PASS` lines. The local check script remains ignored.
```powershell
# Run with working directory C:/Windows. All writes remain in the ignored build directory.
$env:TEMP='C:/Users/adityasharma/Projects/audio.cpp/build/whistle/pr-prep/standalone'
$env:TMP=$env:TEMP
& C:/Users/adityasharma/Projects/audio.cpp/build/whistle/bin/audiocpp_cli.exe --task asr --family whistle_asr --backend cpu --threads 4 --model C:/Users/adityasharma/Projects/audio.cpp/build/whistle/pr-prep/model/whistle-f32.gguf --audio C:/Users/adityasharma/Projects/audio.cpp/assets/resources/sample_16k.wav --language en --metrics --log
```
## Transcript comparison and reference limitations
Measured inputs are 16 kHz mono WAVs. English is the complete repository sample, 14.0719 seconds.
German is the existing local `german_16k.wav`, 2.11594 seconds; its recording provenance is not established.
Their SHA-256 hashes are respectively
`a8096a0c2ac136bcaf70143dc80ba576ade8dfbf7f9f30ecdec29b920ab3e3cb` and
`82c41f2dd56e21fb9407b9d4abf7692d9db8806b41e7134607ea4748411803b1`.
FP32 audio.cpp English output was:
```text
Some call me Nature. Others call me Mother Nature. I've been here for over 4.5 billion years, twenty two thousand five hundred times longer than you.
```
The local quantized official `.cact` reference produced:
```text
Some call me Nature. Others call me Mother Nature. I've been here for over 4.5 billion years, 22,500 times longer than you.
```
Both produced this German transcript:
```text
Guten Morgen, wie wird das Wetter heute?
```
Exact reference commands used automatic language detection, four threads, full/default audio decoder
depth, and no keywords, timestamps, or streaming:
```powershell
& C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/needle.exe --help
& C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/needle.exe --model C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/whistle.cact --audio assets/resources/sample_16k.wav --threads 4
& C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/needle.exe --model C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/whistle.cact --audio C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/reference/german_16k.wav --threads 4
```
The `.cact` hash matches the pinned converter asset hash
`b6e02f048568ac5d01a2042556c658061e699acbc0aa2a1439f52f3d461dffeb`.
The local executable SHA-256 is
`e8863ca0c06a47d406777077f1ba728b58e57d77fedff252dbe6827d588acc8c`.
Its source revision cannot be established from the available files.
The audio.cpp decoder uses greedy selection, a 320-text-token ceiling, and no sampling.
This is a transcript comparison with quantized weights, not exact FP32 numerical parity.
No FP32 reference runtime or seven-language validation corpus was available.
## Performance and process memory
Measured hardware is AMD Ryzen AI MAX+ 395, 16 cores/32 logical processors.
OS is Windows 11 Enterprise Insider Preview, build 26704.
Compiler is MSVC 19.51.36257.0. Final configure used CMake 4.4 and Ninja 1.13.2.
These are local observations, not a performance guarantee.
- Four-thread CLI English request took 319.928 ms for 14.0719 seconds, RTF 0.0227352.
Peak process working set was 446 MiB; reported peak Windows commit was 232 MiB.
- Four-thread CLI German request took 78.3542 ms for 2.11594 seconds, RTF 0.0370305.
Peak working set was 432 MiB; reported peak Windows commit was 219 MiB.
- One loaded CLI session ran two identical English requests.
First request took 467.397 ms, RTF 0.0332149, peak working set 447 MiB.
Repeat took 479.373 ms, RTF 0.0340659, peak working set 448 MiB.
These are first/repeated requests, not OS-cache-cold measurements.
- An ignored one-thread scratch probe using the existing test objects separately measured
asset loading at 23.7752 ms and runtime initialization at 150.822 ms.
Inference then took 640.984 ms and 661.587 ms, RTF 0.0455505 and 0.0470146.
`GetProcessMemoryInfo` reported peak working set 467447808 bytes for both requests.
The scratch probe initially requested four threads from test objects built without OpenMP.
That unsupported probe failed; the successful probe uses one thread. Production CLI uses OpenMP.
CLI metrics time preparation/inference after model/session creation and input loading.
They exclude model loading. Scratch load timings are separate and use a different one-thread harness.
RTF is inference seconds divided by input-audio seconds. All measured RTFs are below 1.0.
Memory figures describe CPU process memory, not GPU memory.
```powershell
$batch = @(@{id='cold';audio='C:/Users/adityasharma/Projects/audio.cpp/assets/resources/sample_16k.wav'},@{id='repeat';audio='C:/Users/adityasharma/Projects/audio.cpp/assets/resources/sample_16k.wav'})
$batch | ConvertTo-Json | Set-Content build/whistle/pr-prep/batch.json
build/whistle/bin/audiocpp_cli.exe --task asr --family whistle_asr --backend cpu --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --request-sequence build/whistle/pr-prep/batch.json --metrics
```
The scratch source, compile logs, exact probe command, model, recordings, input/sidecar hashes,
and run logs remain under ignored `build/whistle/pr-prep/`; they are not PR source artifacts.
The successful probe command is preserved in `benchmark-command.ps1`, executed from `build/whistle`.
Original cached inputs remain unchanged under
`C:/Users/adityasharma/Projects/ansible-windows/.runtime/whistle/`.
## Provenance, licensing, and limitations
See [provenance](../../tools/community_models/whistle_asr/PROVENANCE.md),
[license table](../../docs/model_licenses.md), and
[model guide](../../docs/models/whistle_asr.md).
The converter preserves the pinned upstream `LICENSE` inside the GGUF.
The repository license does not license the weights or extracted assets.
The contributor confirms that no code was copied or adapted from reference implementations.
Independent extracted-asset provenance remains unverified in the upstream release.
No independently audited redistribution rights or maintainer approval is claimed.
Backend coverage is offline CPU and Metal with FP32 weights; see the
[GGML encoder section](#ggml-encoder-rewrite-2026-10-07) below. Supported language codes are
`en`, `de`, `fr`, `es`, `it`, `nl`, `pl`. English and German inputs above were checked.
The other five codes each have one [synthetic CLI spot check](MULTILINGUAL_VALIDATION.md),
with ten successful automatic/forced-language runs and zero normalized prompt word edits.
The quantized reference matches four clips; Polish differs by `niebo` versus
`nie bo`. This is documented without a parity claim.
Their measured four-thread inference RTF ranges from 0.0392522 to 0.0570413;
peak process working set ranges from 432 to 434 MiB. These are not a broad accuracy corpus.
Inputs must be 16 kHz mono, at most 30 seconds. There is no streaming, resampling,
long-form recognition, quantization, keyword biasing, or word timestamps. Metal
accelerates the encoder graph; decoding stays on the CPU.
Noise-only audio may produce words. This family is not a speech-activity detector.
No downloadable package is advertised.
## Pre-submission adversarial review and regression checks
A fresh-context review of the port found a byte-fallback decoding defect.
Invalid byte tokens could return invalid UTF-8, and byte-composed metaspace
markers were not converted to spaces. The model-local decoder now concatenates
visible bytes, applies UTF-8 replacement decoding, then replaces metaspace globally.
No framework or GGML code changes are included.
`whistle_tokenizer_test` adds literal regressions for valid multibyte fallback,
invalid and incomplete UTF-8, overlong/surrogate/out-of-range sequences,
byte-composed metaspace, special-token handling, malformed hex, and ID bounds.
The focused re-review found no remaining Critical or Important issue.
The following checks ran after the fix, in a VS developer shell where applicable:
```powershell
cmake -S . -B build/whistle
cmake --build build/whistle --target whistle_tokenizer_test whistle_frontend_test whistle_assets_test audiocpp_cli --config Release -j 4
build/whistle/bin/whistle_tokenizer_test.exe
ctest --test-dir build/whistle -R whistle_ --output-on-failure
python -X utf8 build/whistle/pr-prep/tokenizer-fix/run_cli_regressions.py | Tee-Object build/whistle/pr-prep/tokenizer-fix/cli-regressions.log
```
Measured results are a successful build, tokenizer-test pass, and CTest **4/4**.
All ten French/Spanish/Italian/Dutch/Polish CLI requests exit 0 and produce
exactly the same transcripts as the earlier recorded automatic/forced runs.
New logs remain ignored under `build/whistle/pr-prep/tokenizer-fix/`.
The reviewer also exercised the advertised extracted-Safetensors directory
through the production CLI with CPU, one thread, English, and the full sample.
It exits 0 with the expected English transcript, 786.915 ms inference wall time,
and peak process working set 447 MiB. This is an additional load-route check,
not a before/after performance comparison.
## GGML encoder rewrite (2026-10-07)
Maintainer review asked for framework-module reuse and GGML graphs. This revision moves
the mel frontend onto `NemoMelFrontend` and the whole encoder (stem, eight mHC layers,
final norm, positional table, cross-attention key/value projections) into one GGML graph
built from `Conv2dModule`, `DepthwiseConv2dModule`, `DepthwiseConv1dModule`,
`LinearModule`, `GemmaRMSNormModule`, `RoPEModule`, `SiluModule`, and `GLUModule`.
Whistle-specific math (mHC lane mixing with Sinkhorn normalization, the conditioned
Kronecker-factored Hadamard MLP with fixed permutations, and 48/64-wide grouped-query
attention) stays in `src/community_models/whistle_asr/encoder.cpp`. The autoregressive
decoder still runs on the host. The converter and GGUF layout are unchanged; the loader
transposes the JAX `[in, out]` kernels into the backend weight store.
Measured on macOS 27.0.1, Apple M3 Ultra, Apple clang, CMake 4.4.4, Ninja, Release,
`ENGINE_ENABLE_METAL=ON`, `GGML_METAL=ON`, OpenMP on, deployment build, custom model set
`whistle_asr`, `ENGINE_BUILD_TESTS=ON`. Exact commands from the repository root:
```bash
scripts/build_metal.sh --build-dir build/whistle --build-type Release --openmp auto --with-tests --deployment-build --model-set custom --models whistle_asr --target audiocpp_cli --target audiocpp_gguf --target whistle_frontend_test --target whistle_tokenizer_test --target whistle_assets_test
python3 tools/community_models/whistle_asr/convert.py --source build/whistle/pr-prep/source --cact /private/tmp/whistle-src/whistle.cact --converter build/bin/audiocpp_gguf --output build/whistle/pr-prep/model/whistle-f32.gguf
cmake -S . -B build/whistle -DAUDIOCPP_WHISTLE_TEST_MODEL=$PWD/build/whistle/pr-prep/model/whistle-f32.gguf
ctest --test-dir build/whistle -R whistle_ --output-on-failure
build/whistle/bin/whistle_frontend_test build/whistle/pr-prep/source/mel_filterbank_80.f32 build/whistle/pr-prep/oracle/sample_16k.f32
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf assets/resources/sample_16k.wav --reference build/whistle/pr-prep/oracle --backend cpu --threads 4
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf assets/resources/sample_16k.wav --reference build/whistle/pr-prep/oracle --backend metal --tolerance 5e-3
build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend metal --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --metrics
python3 tools/check_loader_catalog_sync.py --self-test && python3 tools/check_loader_catalog_sync.py
```
The locally converted GGUF has SHA-256
`80c42e88a1ad07203634b60d50e0763e0478bb6830544434b70f52ab53c9ab42` (220831648 bytes).
The hosted `bumpyclock/whistle-GGUF` file, SHA-256
`1167c811abad2fc539a1116e1b7031ba4a07c8f5b2e75ab3a389ae370efede8b`, was converted from an
earlier spec revision; it loads and produces the same English transcript on CPU and Metal.
### Numerical comparison against the host implementation
Reference tensors were dumped from the previous host-only implementation at commit
`6e0a40c4` on the full 14.0719-second repository sample (1407 mel frames, 176 encoder
frames). That commit has no exporter: the dumps came from a temporary hook that wrote
`mel.values`, `memory`, `output.cross_k[0]`, `output.cross_v[0]`, `output.cross_k[7]`,
and `output.cross_v[7]` from `encode()` in its `runtime.cpp` as raw little-endian f32 in
the layouts those vectors already have (frame-major: `[frames, 80]`, `[frames, 512]`,
`[frames, 384]`, `[frames, 512]`). Regenerating them means checking out `6e0a40c4`,
adding such a hook, and running the CLI once on `assets/resources/sample_16k.wav` with
one thread. The dumps stay in ignored `build/whistle/pr-prep/oracle/`. The current test
writes the same six tensors from the current implementation with
`whistle_assets_test <gguf> <wav> --dump <dir>`, so later refactors have a committed
oracle recipe. `whistle_assets_test --reference` reports the largest absolute difference
as a fraction of each reference tensor's largest magnitude:
| Tensor | Reference max | CPU fraction | Metal fraction |
|---|---:|---:|---:|
| mel features (1407 x 80) | 3.42311 | 6.37e-06 | 6.37e-06 |
| encoder memory (176 x 512) | 5.55704 | 1.24e-06 | 6.79e-04 |
| cross-attention keys, layer 0 | 7.92314 | 4.46e-06 | 2.57e-03 |
| cross-attention values, layer 0 | 86.5275 | 1.15e-06 | 5.21e-04 |
| cross-attention keys, layer 7 | 9.34248 | 1.17e-06 | 1.10e-03 |
| cross-attention values, layer 7 | 197.274 | 9.57e-07 | 4.25e-04 |
Per-layer probes during bring-up showed the Metal drift appears already after the stem
(2.5e-04 of scale) and stays between 1e-04 and 4e-04 of scale through the eight layers.
This is consistent with ggml's Metal F32 matrix kernel, `kernel_mul_mm_f32_f32`, which
stages both operands in half precision before accumulating in float; the Metal backend
does not read the `ggml_mul_mat_set_prec` flag, so requesting F32 precision changes
nothing there. The CPU graph tracks the host implementation to about 1e-06 of scale.
`whistle_frontend_test` passes both its fixed vectors and the official filterbank check
on the real sample (tolerance 0.02). The default `--reference` tolerance is 2e-3, which
the CPU graph passes by three orders of magnitude; Metal needs `--tolerance 5e-3`.
Whistle's mHC residual logits reach gaps above 200 nats inside one 4x4 Sinkhorn block
(layer 1), so a float softmax underflows to exact zeros. ggml has no max reduction, so
the graph recovers each row's log-sum-exp as `sum(p * (x - log p))` from the clamped
softmax and subtracts it from `x`, which keeps the host implementation's exact
`x - logsumexp(x)` values for entries far below the row maximum. An earlier bring-up
variant used `log(clamp(softmax))` instead; that discards differences between
underflowed entries, which a later column pass can amplify, and was replaced before
this revision was finalized.
### Transcripts and tests
CTest passed 4/4 (`whistle_frontend_test`, `whistle_tokenizer_test`,
`whistle_asr_integration`, `whistle_asr_full_integration`). The full-sample integration
checks (expected transcript, decoder reuse, quiet and constant-RMS speech, silence,
30-second rejection, non-finite rejection) also pass with `--backend metal`. The English
sample transcript is unchanged on CPU with 1 and 4 threads and on Metal:
```text
Some call me Nature. Others call me Mother Nature. I've been here for over 4.5 billion years, twenty two thousand five hundred times longer than you.
```
Seven longer synthetic clips (20 to 26 seconds, one per supported language) produce
byte-identical transcripts across CPU 1 thread, CPU 4 threads, Metal, automatic and
forced language; see [the multilingual report](MULTILINGUAL_VALIDATION.md#longer-seven-language-clips-2026-10-07).
The previous host implementation was not run on those clips; they compare the new
backends against each other, not against the old code.
### Performance and process memory
CLI `metrics.wall_ms` covers preparation plus inference per request and excludes model
loading. Encoder-only times come from `whistle_assets_test --reference` (graph build,
allocation, upload, compute, readback) for the 176-frame sample; "first" includes
backend warm-up, "warm" is the second call. The previous host code was rebuilt from
commit `6e0a40c4` in a worktree with the same build script and run three times per
configuration on the same binary model; both logs are in ignored
`build/whistle/pr-prep/baseline/` (`cli-sample-6e0a40c4.log`, `cli-sample-final.log`).
Single local observations, not guarantees.
| Measurement | Previous host code (commit `6e0a40c4`) | GGML encoder revision |
|---|---:|---:|
| Sample, CLI wall, CPU 1 thread, 3 runs | 505 to 517 ms | 482 to 516 ms |
| Sample, CLI wall, CPU 4 threads, 3 runs | 253 to 259 ms | 237 to 238 ms |
| Sample, CLI wall, Metal, 3 runs | not supported | 157 to 164 ms |
| Encoder only, CPU 1 thread | not measured separately | 302 ms first, 295 ms warm |
| Encoder only, CPU 4 threads | not measured separately | 105 ms first, 90 ms warm |
| Encoder only, Metal | not supported | 143 ms first, 37 ms warm |
| Sample, peak RSS | 605 to 609 MiB | 758 to 767 MiB |
At one CPU thread the end-to-end time is unchanged within run-to-run noise: the GGML
encoder is not faster than the hand-written loops single-threaded, and the exact
Sinkhorn normalization adds about 1300 small graph nodes. Four threads are about 7%
faster, and Metal is about 1.6x faster than four CPU threads. On Metal the encoder is
about 37 ms of a 160 ms sample request, so the host decoder is the dominant remaining
cost. Peak RSS grew by about 150 MiB because encoder weights now live in the backend
weight store while the decoder keeps its host copies of the decoder tensors.
Five identical English requests (20.43 s clip) in one loaded CLI session:
| Backend | Wall per request | Peak RSS | Peak footprint |
|---|---|---|---|
| CPU 4 threads | 392.6, 385.5, 385.0, 379.3, 379.9 ms | 770 to 774 MiB | 537 to 541 MiB |
| Metal | 275.6, 252.5, 262.2, 261.7, 261.0 ms | 746 to 748 MiB | 802 to 803 MiB |
```bash
build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend metal --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --request-sequence build/whistle/pr-prep/longform/batch5.json --metrics
```
Catalog self-tests passed 2/2; synchronization passed. No framework, GGML, converter, or
GGUF-format changes are included. CUDA, HIP, and Vulkan remain unvalidated and are
rejected at session creation.
## Readiness checklist
- [x] Community layout, namespace, registration, build paths, and discoverability updated.
- [x] Fresh self-contained GGUF embeds the updated specification and license/assets.
- [x] Catalog checks, CPU builds, frontend, and opt-in integration tests passed.
- [x] GGML encoder matches the host implementation on CPU (about 1e-06 of scale) and Metal (at most 1.2e-03 of scale); macOS CTest 4/4 and Metal integration checks pass.
- [x] GGML decoder step graph matches the host decoder's per-step logits on CPU (about 2e-06 of scale) with identical transcripts on the sample and seven long clips; see the decoder section below.
- [x] Actual CLI English/German, silence, duration rejection, streaming rejection, and session reuse checked.
- [x] Local latency, RTF, and CPU process memory recorded without excluding slow requests.
- [x] Final diff and untracked-file list inspected. No generated model/log/audio artifacts enter source changes.
- [x] Relative documentation links resolve, old core paths are absent, and `git diff --check` passes.
- [ ] Resolve documented source-specific licensing uncertainties before redistribution.
- [ ] Establish the local reference executable's exact source revision for a revision-identified comparison.
- [ ] Obtain a pinned FP32 reference runtime before claiming FP32 numerical parity.
- [x] French, Spanish, Italian, Dutch, and Polish synthetic spot checks completed through the actual CLI.
- [ ] Broaden native-speaker and real-recording coverage; current evidence is not a general accuracy benchmark.
- [ ] Force decode exhaustion in a focused behavioral test if required by reviewers; currently only code-inspected.
These validation runs did not publish model files or download large dependencies.
The report records preparation evidence before PR submission.
## GGML decoder step graph (2026-10-08)
### What changed
The autoregressive decoder now runs as one persistent GGML step graph. Its
self-attention keys and values live in a `TransformerKVCache`. The code is in
`src/community_models/whistle_asr/decoder.cpp`. It uses framework modules and the
Whistle graph pieces that the encoder also uses (`graph.cpp`, `graph.h`). The host
decoder math, the host copies of the decoder weights, and the OpenMP handling are
gone from `runtime.cpp`.
Two things stay on the host: the engram table lookup (integer hashing over the token
history) and the greedy argmax. The engram projections and gates run in the graph.
The decoder step graph runs on its own single-thread CPU execution context, whatever
backend the session uses. The encoder stays on the session backend (CPU or Metal). No framework, GGML, converter, or GGUF changes are included.
### Reference dumps
Before the port, `whistle_assets_test --dump` (new in this branch) recorded the host
decoder's per-step logits, input tokens, and transcript. It recorded them for the sample
(`assets/resources/sample_16k.wav`, 42 steps) and for seven synthetic clips of 20 to 26
seconds. The clips are the same local files as the long-form section of
[the multilingual report](MULTILINGUAL_VALIDATION.md), in
`build/whistle/pr-prep/longform/`.
| Clip | en | de | fr | es | it | nl | pl |
|---|---:|---:|---:|---:|---:|---:|---:|
| Steps | 89 | 172 | 156 | 177 | 162 | 107 | 212 |
The dumps are in `build/whistle/pr-prep/decoder-reference/<clip>/`. They are local and
git-ignored. The encoder tensors in them came from the merged encoder on `main`.
Measured on the same machine as the section above: Apple M3 Ultra, macOS, Release,
`GGML_METAL=ON`, OpenMP on, custom model set `whistle_asr`. Exact commands from the
repository root:
```bash
cmake --build build/whistle --target whistle_assets_test audiocpp_cli -j 8
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf assets/resources/sample_16k.wav --backend cpu --threads 1 --dump build/whistle/pr-prep/decoder-reference/sample
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf <wav> --backend cpu --threads 1 --reference build/whistle/pr-prep/decoder-reference/<clip> --tolerance 1e-4
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf <wav> --backend cpu --threads 4 --reference build/whistle/pr-prep/decoder-reference/<clip> --tolerance 1e-4
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf <wav> --backend metal --threads 4 --reference build/whistle/pr-prep/decoder-reference/<clip> --tolerance 5e-3
ctest --test-dir build/whistle -R whistle_ --output-on-failure
build/whistle/bin/whistle_assets_test build/whistle/pr-prep/model/whistle-f32.gguf assets/resources/sample_16k.wav "<sample transcript>" --full --backend metal
build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend cpu --threads 1 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --language en --metrics
build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend cpu --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --language en --metrics
build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend metal --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --language en --metrics
build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend <cpu|metal> --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --request-sequence build/whistle/pr-prep/batch2.json --metrics
/usr/bin/time -l build/whistle/bin/audiocpp_cli --task asr --family whistle_asr --backend <cpu|metal> --threads 4 --model build/whistle/pr-prep/model/whistle-f32.gguf --audio assets/resources/sample_16k.wav --language en
```
`batch2.json` holds two requests, `cold` and `repeat`, with the same absolute path to
`sample_16k.wav`.
### Results at the final commit
Every comparison against the reference dumps passed. Tokens and transcripts are
identical on every clip and in every configuration. The encoder tensors are bit-identical
to the dumps on CPU (largest fraction 0).
The table gives the largest per-step difference in decoder logits, as a fraction of the
reference's largest value. CPU with 1 thread and CPU with 4 threads give the same values.
| Clip | Steps | CPU | Metal (encoder on Metal, decoder on CPU) |
|---|---:|---:|---:|
| sample | 42 | 1.86e-6 | 1.53e-4 |
| en | 89 | 1.70e-6 | 1.66e-4 |
| de | 172 | 1.71e-6 | 1.43e-4 |
| fr | 156 | 1.75e-6 | 3.98e-4 |
| es | 177 | 1.55e-6 | 1.03e-4 |
| it | 162 | 1.75e-6 | 6.05e-4 |
| nl | 107 | 1.75e-6 | 5.43e-4 |
| pl | 212 | 1.60e-6 | 2.06e-4 |
- The CPU difference of about 2e-6 comes from a different floating-point summation order.
- The largest encoder fraction on Metal is 2.57e-3 on the sample and up to 1.47e-3 on
the long clips. This Metal encoder drift existed before this branch. See the section
above. The decoder runs the same graph on both backends, so the larger Metal logits
difference most likely comes from that drift.
- `ctest -R whistle_` passed 4/4 (frontend, tokenizer, integration, full integration).
- The Metal `--full` sample check passed.
- A two-request session gave identical transcripts on CPU and on Metal.
- The test now rejects `--dump` and `--reference` on the same directory. It also rejects
either option without a WAV file.
### Timing
Sample, 14.07 seconds. CLI `metrics.wall_ms` (preparation plus inference), three runs
each. "Before" is the merged `main` with the host decoder, measured on this machine on
2026-10-08 before the port. "Decoder on session backend" is this branch with one
local change, the decoder built on the session context instead of its own; it is
measured only for the comparison and is not shipped. In a CPU session with one thread
it is the same configuration as the shipped build.
| Configuration | Before (host decoder) | Decoder on session backend (not shipped) | This branch (decoder on 1 CPU thread) |
|---|---:|---:|---:|
| CPU 1 thread | 494 to 528 ms | same as this branch | 465 to 472 ms |
| CPU 4 threads | 238 to 242 ms | 1933 to 2242 ms | 282 to 283 ms |
| Metal | 157 to 163 ms | 618 to 629 ms | 204 to 226 ms |
Encoder and decoder split in this branch, from the CLI `--log` timing output.
`whistle_asr.encode_ms` includes the mel frontend and the per-request encoder graph
build. `whistle_asr.decode_ms` covers the 42 steps.
| Configuration | encode_ms | decode_ms |
|---|---:|---:|
| CPU 1 thread | 330 to 336 | 134 to 140 |
| CPU 4 threads | 146 to 148 | 134 to 136 |
| Metal | 65 to 87 | 136 to 141 |
To attribute the difference, `main` was built locally with the same two timing
scalars added to `runtime.cpp` and measured on the same day, three runs each:
| Configuration | Encoder before | Encoder this branch | Decoder before (host, OpenMP) | Decoder this branch (graph, 1 thread) |
|---|---:|---:|---:|---:|
| CPU 1 thread | 333 to 339 ms | 330 to 336 ms | 164 to 170 ms | 134 to 140 ms |
| CPU 4 threads | 140 to 158 ms | 146 to 148 ms | 103 to 106 ms | 134 to 136 ms |
| Metal | 59 to 70 ms | 65 to 87 ms | 97 to 102 ms | 136 to 141 ms |
The encoder path is unchanged within run-to-run variation. The whole difference is
the decoder: the host decoder spread its matrix-vector products over OpenMP threads,
and the step graph runs on one thread.
The decoder graph has 4738 nodes per step.
Why the decoder runs on one CPU thread: a one-token step is thousands of small nodes,
so its cost is per-node overhead, not arithmetic. `whistle_asr.decode_ms` for the 42
sample steps, three runs each: 134 to 140 ms on one CPU thread; 550 to 560 ms on Metal;
1014 to 1111 ms on a private CPU context with 2 threads (Metal session); 1787 to 2098 ms
on the CPU session backend with 4 threads. Each node synchronizes the threads, so more
threads cost more.
Longer clips on Metal, one run each:
| Clip | Audio | Wall | RTF |
|---|---:|---:|---:|
| en | 20.43 s | 357 ms | 0.0175 |
| pl | 22.94 s | 761 ms | 0.0332 |
Two requests in one session: CPU with 4 threads took 293 ms, then 284 ms. Metal took
198 ms, then 184 ms.
### Process memory
Maximum resident set size from `/usr/bin/time -l`, sample:
| Backend | Before (host decoder) | This branch |
|---|---:|---:|
| CPU 4 threads | 758 MiB | 729 MiB |
| Metal | 765 MiB | 720 MiB |
The decoder weights no longer have host copies. The engram tables (two 37.7 MB f32
tables) stay on the host.
### Review fixes recorded
- An intermediate Sinkhorn form (column potentials, fewer nodes) was replaced by the
original row-then-column log normalization. In a float check with logits spread far
wider than the model produces, a column whose entries all underflow gave NaN. With
the original form the encoder is bit-identical again. The decoder step time is
unchanged.
- The decoder state buffer is now freed if construction fails after it was allocated.
### Known limitations and follow-ups
- Metal and 4-thread CPU requests are slower than with the host decoder (see the timing
table). One-thread CPU is slightly faster.
- Cross-attention keys and values are read back from the encoder graph and uploaded to
the decoder tensors once per request.
- The engram tables stay on the host.
- The encoder graph is still built per request.
- CUDA, Vulkan, and HIP are not validated. Sessions still reject them.
- The input set is the same as before: the sample plus seven synthetic clips. It is not
a broad corpus.