From cb958306ee6c7b4167d6fe59e78b28561bef79bc Mon Sep 17 00:00:00 2001 From: plotarmordev <299844489+plotarmordev@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:21:32 +0800 Subject: [PATCH] feat(engine): I pin the full model to TensorFold v1.1.9 with no patches TensorFold v1.1.9 (465e48b6) carries every full-model patch I shipped: 0001-0011 since v1.1.4, the reference-prefill reuse (0012, a06a4dff) since v1.1.5 and the request sampling controls (0013/0014, #1277) since v1.1.6. It also has the slot-release fix (7e43ebeab, #1438) since v1.1.8. 0013 and 0014 no longer apply to v1.1.9 because their files are already there; the patched speech_sampling sources differ from v1.1.9 only by upstream's +0 temperature fix (4a556244). - engine/UPSTREAM moves to v1.1.9; engine/patches keeps an empty SHA256SUMS that declares the empty series. - scripts/build-engine.sh accepts an empty series only through an empty manifest; a missing manifest or a listed-but-absent patch still fails before checkout. A new build test covers both. - The Linux pilot sets TF_DRAMA_PREFILL_ROWS=128 for full and custom checkpoints, because upstream defaults to single-row prefill and the old patch 0001 turned batched prefill on. - README, patch README, CONTRIBUTING, NOTICE, macOS and sampling docs and CHANGELOG describe the new pin and its two behaviour differences. --- CHANGELOG.md | 10 + CONTRIBUTING.md | 2 +- NOTICE | 3 +- README.md | 63 +- engine/UPSTREAM | 4 +- ...h-CUDA-prompt-prefill-in-bounded-rou.patch | 503 ------ ...ete-replay-poison-before-resetting-s.patch | 94 -- ...ech-configure-the-Drama-frame-budget.patch | 272 ---- ...er-speech-controls-with-an-opt-in-al.patch | 627 -------- ...-serve-attested-Drama-voice-catalogs.patch | 634 -------- ...h-add-opt-in-sustained-silence-guard.patch | 641 -------- ...opt-in-silence-onset-rule-to-the-sil.patch | 342 ---- ...opt-in-silence-mid-s-lost-ending-tri.patch | 544 ------- ...aw-whole-replies-the-silence-guard-e.patch | 1291 --------------- ...aw-whole-replies-that-stopped-short-.patch | 389 ----- ...aw-a-whole-reply-once-when-a-phrase-.patch | 1380 ----------------- ...e-a-voice-reference-s-prefill-across.patch | 770 --------- .../0013-speech-sampling-controls.patch | 536 ------- .../0014-sampling-json-boundaries.patch | 90 -- engine/patches/README.md | 27 +- engine/patches/SHA256SUMS | 14 - recipe/README-macos.md | 4 +- recipe/README-sampling.md | 5 +- recipe/pilot.py | 3 +- recipe/test_build_engine.py | 15 + recipe/test_weight_selection.py | 6 + recipe/weight_selection.py | 5 + scripts/build-engine.sh | 9 +- 28 files changed, 94 insertions(+), 8189 deletions(-) delete mode 100644 engine/patches/0001-feat-drama3-batch-CUDA-prompt-prefill-in-bounded-rou.patch delete mode 100644 engine/patches/0002-fix-drama3-complete-replay-poison-before-resetting-s.patch delete mode 100644 engine/patches/0003-feat-speech-configure-the-Drama-frame-budget.patch delete mode 100644 engine/patches/0004-feat-server-filter-speech-controls-with-an-opt-in-al.patch delete mode 100644 engine/patches/0005-feat-server-serve-attested-Drama-voice-catalogs.patch delete mode 100644 engine/patches/0006-feat-speech-add-opt-in-sustained-silence-guard.patch delete mode 100644 engine/patches/0007-feat-speech-add-opt-in-silence-onset-rule-to-the-sil.patch delete mode 100644 engine/patches/0008-feat-speech-add-opt-in-silence-mid-s-lost-ending-tri.patch delete mode 100644 engine/patches/0009-feat-speech-redraw-whole-replies-the-silence-guard-e.patch delete mode 100644 engine/patches/0010-feat-speech-redraw-whole-replies-that-stopped-short-.patch delete mode 100644 engine/patches/0011-feat-speech-redraw-a-whole-reply-once-when-a-phrase-.patch delete mode 100644 engine/patches/0012-feat-drama3-reuse-a-voice-reference-s-prefill-across.patch delete mode 100644 engine/patches/0013-speech-sampling-controls.patch delete mode 100644 engine/patches/0014-sampling-json-boundaries.patch diff --git a/CHANGELOG.md b/CHANGELOG.md index 820b66e..7658f09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +### Changed + +- The full-model engine pins TensorFold v1.1.9 with an empty patch series: every behaviour of the + old patches 0001–0014 is upstream. Two differences remain: with the mid-silence rule on, + `X-Silence-Mid-Trips` reports only whether the returned take tripped (0 or 1), and a temperature + of `-0` or `-1e-400` reaches the sampler as `+0`. `scripts/build-engine.sh` accepts an empty + series only through an empty `SHA256SUMS`; a missing manifest still fails. The Linux native pilot + sets `TF_DRAMA_PREFILL_ROWS=128` for full and custom checkpoints to keep batched prefill; NVFP4 + pins and the sglang reference backend are unchanged. + ### Fixed - CUDA NVFP4 long-form splitting preserves paired delivery tags, bracketed cues and special tokens, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dee60c8..c5f16e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,7 @@ series and check that the whole series still applies and builds from a clean che ```sh cd build/tensorfold -git format-patch --start-number 12 -o ../../engine/patches HEAD~2 # two new commits; 0012 is the next free number +git format-patch --start-number 1 -o ../../engine/patches HEAD~2 # two new commits; the v1.1.9 series is empty, so 0001 is next cd ../../engine/patches && sha256sum -- *.patch > SHA256SUMS cd ../.. && ZIG=/path/to/zig-0.17.0/zig NVCC=/usr/local/cuda/bin/nvcc scripts/build-engine.sh ``` diff --git a/NOTICE b/NOTICE index 14556bc..bfbdf1d 100644 --- a/NOTICE +++ b/NOTICE @@ -11,7 +11,8 @@ TensorFold ---------- The engine patches apply to TensorFold (https://github.com/ashhart/TensorFold), which is licensed under the Apache License, Version 2.0. scripts/build-engine.sh clones TensorFold at the commit pinned in engine/UPSTREAM -and applies the patches with `git am`. On Linux, --weights nvfp4 instead clones the TensorFold fork +(v1.1.9) and applies the patches in engine/patches/ with `git am`; that series is currently empty, so the full +model builds unmodified TensorFold. On Linux, --weights nvfp4 instead clones the TensorFold fork https://github.com/plotarmordev/TensorFold at the commit pinned in engine/nvfp4/UPSTREAM, also Apache-2.0, and applies the ordered patches in engine/nvfp4/patches/ after checking their SHA256SUMS manifest. On Darwin, --weights nvfp4 uses upstream TensorFold at engine/nvfp4/macos/UPSTREAM and applies the ordered patches in diff --git a/README.md b/README.md index c3dc9e8..7f7247a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ **Fish Audio's Drama 3 text-to-speech on NVIDIA GB10 (native Zig/CUDA) or Apple silicon (native Zig/Metal).** [TensorFold](https://github.com/ashhart/TensorFold) runs Drama 3 natively since v1.1.0. This repository pins -upstream TensorFold and adds a small series of opt-in patches for the full model. NVFP4 retains its +TensorFold v1.1.9 for the full model, which now carries every feature the old patch series added. NVFP4 retains its measured CUDA engine pin with a checksummed patch series and uses a separate upstream pin plus Metal patches on Mac. It builds the engine and starts a local speech server with an OpenAI-style `/v1/audio/speech` route, whole or streamed. @@ -69,7 +69,7 @@ On Linux, `--weights nvfp4` builds [`engine/nvfp4/UPSTREAM`](engine/nvfp4/UPSTRE guidance γ 1.5 and batched guidance, as measured; a `--cfg-gamma` of your own after `--` replaces both. On Mac it builds [`engine/nvfp4/macos/UPSTREAM`](engine/nvfp4/macos/UPSTREAM) plus its patch series. Metal's `--cfg-batched` pairs independent states in one command buffer, not a vectorized GEMM. -`--weights full` retains [`engine/UPSTREAM`](engine/UPSTREAM) and [`engine/patches`](engine/patches). +`--weights full` builds [`engine/UPSTREAM`](engine/UPSTREAM) with the empty [`engine/patches`](engine/patches) series. When it prints `DRAMA_PILOT_READY`, synthesize from another terminal. The second command streams and plays as it arrives: @@ -159,34 +159,35 @@ See the [CUDA patch index](engine/nvfp4/patches/README.md) for scope and tests. ### Full-model engine -These patches build the full-model engine (`--weights full`). Upstream TensorFold already serves Drama 3. Each patch -below adds one feature that stays off until you pass its flag, so a server started without them answers as -upstream does. The exceptions are the first two and one request-level feature: batched prefill is on by default -(`TF_DRAMA_PREFILL_ROWS=1` restores upstream's single-row prefill), the replay fix is a bug fix, and per-request -`temperature`, `top_p`, `top_k` and `seed` (0013/0014) need no launch flag. A request without those fields is -answered as upstream answers it; upstream refuses them with a 400, and these patches serve them. The CUDA NVFP4 -series (0002) and the Metal NVFP4 series (0004, 0005) carry the same request-level feature. The patches stack and -apply in order; each one's notes are in the engine tree's `CHANGELOG.md` or `docs/`. - -| Patch | Adds | Opt in with | Upstream | -|---|---|---|---| -| 0001 (K0) | CUDA prompt prefill in bounded row chunks | on by default; `TF_DRAMA_PREFILL_ROWS` | [#1120](https://github.com/ashhart/TensorFold/pull/1120) | -| 0002 | Replay waits for poison fills before buffers are reused | always on | [#1119](https://github.com/ashhart/TensorFold/pull/1119) | -| 0003 (K1) | A configurable frame budget | `--max-frames N` | [#1134](https://github.com/ashhart/TensorFold/pull/1134) | -| 0004 (K7) | An allowlist for bracketed speech controls | `--allowed-controls LIST` | [#1152](https://github.com/ashhart/TensorFold/pull/1152) | -| 0005 (K9) | Attested voice catalogs and `GET /v1/voices` | `--voices DIR`, `--default-voice NAME` | [#1135](https://github.com/ashhart/TensorFold/pull/1135) | -| 0006 (K2) | A sustained-silence guard that ends a reply as `silence` | `--silence-floor-dbfs`, `--silence-seconds` | [#1157](https://github.com/ashhart/TensorFold/pull/1157) | -| 0007 (K3) | The guard's onset rule: silence before enough voiced audio | `--silence-onset-s`, `--silence-onset-voiced-s` | [#1158](https://github.com/ashhart/TensorFold/pull/1158) | -| 0008 (K4) | The guard's mid rule: an ending lost in a long silence | `--silence-mid-s`, `--silence-mid-min-voiced-s` | [#1159](https://github.com/ashhart/TensorFold/pull/1159) | -| 0009 (K5) | Whole replies the guard ended are drawn again | `--silence-redraws N`, `--silence-redraw-budget-s`, `--silence-redraw-controls` | [#1160](https://github.com/ashhart/TensorFold/pull/1160) | -| 0010 (K6) | Whole replies that stopped short of their text are drawn again | `--short-reply-ratio`, `--short-reply-chars-per-s` | [#1161](https://github.com/ashhart/TensorFold/pull/1161) | -| 0011 (K8) | A whole reply is drawn once more when a phrase opens far above the others | `--register-redraw-st`, `--register-redraw-high`, `--register-high-hz` | [#1162](https://github.com/ashhart/TensorFold/pull/1162) | -| 0012 | One bounded reference-prefix snapshot per Metal lane, with exact identity and safe invalidation | none (automatic) | [#1237](https://github.com/ashhart/TensorFold/pull/1237) | -| 0013 | Request-local speech sampling and exact integer seeds | HTTP `temperature`, `top_p`, `top_k`, `seed` | [#1277](https://github.com/ashhart/TensorFold/pull/1277) | -| 0014 | Parsed-JSON floating boundary documentation and regressions | none (tests/docs) | [#1277](https://github.com/ashhart/TensorFold/pull/1277) | - -`drama3-voice`, a tool that writes attested voice files for `--voices`, is proposed upstream in -[#1153](https://github.com/ashhart/TensorFold/pull/1153); it is not part of this series. +The full-model engine (`--weights full`) is TensorFold v1.1.9 with no patches: every feature the old patch series +added is upstream. Each flagged feature stays off until you pass its flag, so a server started without them answers as plain +TensorFold does. The Linux recipe sets `TF_DRAMA_PREFILL_ROWS=128` for native full-model and custom-checkpoint +launches to keep batched prefill; TensorFold otherwise defaults to single-row prefill. NVFP4 and the sglang +reference backend do not receive this setting. Per-request `temperature`, `top_p`, `top_k` and `seed` need no +launch flag; the CUDA NVFP4 series (0002) and the Metal NVFP4 series (0004, 0005) carry the same request-level +feature. Each feature's notes are in the engine tree's `CHANGELOG.md` or `docs/`. + +| In TensorFold 1.1.9 | Opt in with | Upstream | +|---|---|---| +| CUDA prompt prefill in bounded row chunks | `TF_DRAMA_PREFILL_ROWS` (the recipe sets `128`) | [#1120](https://github.com/ashhart/TensorFold/pull/1120) | +| Replay waits for poison fills before buffers are reused | always on | [#1119](https://github.com/ashhart/TensorFold/pull/1119) | +| A configurable frame budget | `--max-frames N` | [#1134](https://github.com/ashhart/TensorFold/pull/1134) | +| An allowlist for bracketed speech controls | `--allowed-controls LIST` | [#1152](https://github.com/ashhart/TensorFold/pull/1152) | +| Attested voice catalogs and `GET /v1/voices` | `--voices DIR`, `--default-voice NAME` | [#1135](https://github.com/ashhart/TensorFold/pull/1135) | +| A sustained-silence guard that ends a reply as `silence` | `--silence-floor-dbfs`, `--silence-seconds` | [#1157](https://github.com/ashhart/TensorFold/pull/1157) | +| The guard's onset rule: silence before enough voiced audio | `--silence-onset-s`, `--silence-onset-voiced-s` | [#1158](https://github.com/ashhart/TensorFold/pull/1158) | +| The guard's mid rule: an ending lost in a long silence | `--silence-mid-s`, `--silence-mid-min-voiced-s` | [#1159](https://github.com/ashhart/TensorFold/pull/1159) | +| Whole replies the guard ended are drawn again | `--silence-redraws N`, `--silence-redraw-budget-s`, `--silence-redraw-controls` | [#1160](https://github.com/ashhart/TensorFold/pull/1160) | +| Whole replies that stopped short of their text are drawn again | `--short-reply-ratio`, `--short-reply-chars-per-s` | [#1161](https://github.com/ashhart/TensorFold/pull/1161) | +| A whole reply is drawn once more when a phrase opens far above the others | `--register-redraw-st`, `--register-redraw-high`, `--register-high-hz` | [#1162](https://github.com/ashhart/TensorFold/pull/1162) | +| One bounded reference-prefix snapshot per Metal lane, with exact identity and safe invalidation | none (automatic) | [#1237](https://github.com/ashhart/TensorFold/pull/1237) | +| Request-local speech sampling and exact integer seeds, with parsed-JSON boundary docs | HTTP `temperature`, `top_p`, `top_k`, `seed` | [#1277](https://github.com/ashhart/TensorFold/pull/1277) | + +Every old patch's behaviour is in v1.1.9 with two differences. With the mid-silence rule on, `X-Silence-Mid-Trips` +reports only whether the returned take tripped (0 or 1); takes discarded by silence/short/register redraws are no +longer counted. A temperature of `-0` or `-1e-400` reaches the sampler as `+0` rather than `-0`; both were already +greedy. `drama3-voice`, the upstream tool that writes attested voice files for `--voices`, is in TensorFold; the +full-model build script still builds only the native server and `drama3-generate`. Pass serve flags to the launcher after `--`. An illustrative launch with the frame budget, the silence guard and one redraw; the numbers are examples, not tuned values, so calibrate thresholds on your own voice: @@ -195,7 +196,7 @@ one redraw; the numbers are examples, not tuned values, so calibrate thresholds python3 recipe/pilot.py … -- --max-frames 2048 --silence-floor-dbfs -50 --silence-seconds 6 --silence-redraws 1 ``` -Patch 0004's cue list holds only words from Fish Audio's public tags; deployments may extend the cue list locally. +The speech-control allowlist holds only words from Fish Audio's public tags; deployments may extend it locally. A voice catalog is a directory of `drama3-http-voice/1` files, each with a `source` attestation (`synthetic`, `self` or `consented`); the format is in `docs/drama-http-voices.md` in the engine tree. Clone only a voice the diff --git a/engine/UPSTREAM b/engine/UPSTREAM index d87e840..912e693 100644 --- a/engine/UPSTREAM +++ b/engine/UPSTREAM @@ -1,3 +1,3 @@ -# The upstream engine the patch series in engine/patches applies to. +# TensorFold v1.1.9, the upstream engine the patch series in engine/patches applies to. TENSORFOLD_REPOSITORY=https://github.com/ashhart/TensorFold.git -TENSORFOLD_COMMIT=d1018b318fb51981c4a18f12e40269a4d3ee9713 +TENSORFOLD_COMMIT=465e48b624202d027cb72144749753578846aa7c diff --git a/engine/patches/0001-feat-drama3-batch-CUDA-prompt-prefill-in-bounded-rou.patch b/engine/patches/0001-feat-drama3-batch-CUDA-prompt-prefill-in-bounded-rou.patch deleted file mode 100644 index f97d451..0000000 --- a/engine/patches/0001-feat-drama3-batch-CUDA-prompt-prefill-in-bounded-rou.patch +++ /dev/null @@ -1,503 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: plotarmordev <299844489+plotarmordev@users.noreply.github.com> -Date: Tue, 20 Oct 2026 13:20:22 +0800 -Subject: [PATCH 01/11] feat(drama3): batch CUDA prompt prefill in bounded - rounds - ---- - CHANGELOG.md | 4 ++ - docs/drama3-prefill.md | 49 ++++++++++++++++++ - tools/zig/drama_prefill_compare.py | 68 +++++++++++++++++++++++++ - tools/zig/test_drama_prefill_compare.py | 42 +++++++++++++++ - zig/src/families/drama3/check.zig | 24 +++++++-- - zig/src/families/drama3/common.zig | 66 +++++++++++++++++++++++- - zig/src/families/drama3/cuda/engine.zig | 5 +- - zig/src/families/drama3/drama3.zig | 1 + - zig/src/families/drama3/generate.zig | 3 +- - zig/src/native/drama_host.zig | 17 +++++-- - zig/src/native/drama_open.zig | 12 ++++- - 11 files changed, 276 insertions(+), 15 deletions(-) - create mode 100644 docs/drama3-prefill.md - create mode 100644 tools/zig/drama_prefill_compare.py - create mode 100644 tools/zig/test_drama_prefill_compare.py - -diff --git a/CHANGELOG.md b/CHANGELOG.md -index 61647629..975aaee8 100644 ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -2,6 +2,10 @@ - - Each release's page on GitHub has its notes and measurements. See [the 1.0.0 release notes](RELEASE-NOTES-1.0.0.md) for the native binary's supported models and migration details. - -+## Unreleased -+ -+- Drama 3 on CUDA prefills bounded row chunks in each shared round. `TF_DRAMA_PREFILL_ROWS` selects 1 through 1024 rows (default 128); the generation tool uses `--max-rows`. Batched projections and attention can change rounding relative to single-row prefill; oracle checks now compare the selected prompt chunks at their reference-row offsets. -+ - ## 1.1.1 - - - The app checks models through HTTPS Hub fake-IP proxies in both address families and honours one validated `HF_ENDPOINT` for checks and downloads. Network-policy refusals name the DNS address and proxy remedy (#1037). Thanks to @samwang0041-star. -diff --git a/docs/drama3-prefill.md b/docs/drama3-prefill.md -new file mode 100644 -index 00000000..8a6c7803 ---- /dev/null -+++ b/docs/drama3-prefill.md -@@ -0,0 +1,49 @@ -+# Drama 3 CUDA prompt chunks -+ -+The native speech host advances each prompt by at most `TF_DRAMA_PREFILL_ROWS` rows per shared round. The setting accepts integers from 1 through 1024 and defaults to 128. `drama3-generate --max-rows` selects its engine capacity, from 1 through 8192, also defaulting to 128. The larger engine bound accommodates whole-context oracle scratch; it is not the recommended serving batch. Invalid capacities fail before checkpoint or GPU allocation. Attention query chunks must be from 1 through 1024; their effective size is bounded by the allocated row capacity. -+ -+Each prompt starts at offset zero and takes full chunks followed by its remaining rows. Chunk boundaries do not depend on the number of active lanes, request timing or queue order. The last backbone row is retained for decode; the tower consumes only prompt rows with a successor. A shared round still returns to the scheduler after its chunk. Cancellation granularity is therefore a chunk, not a row. -+ -+The setting `TF_DRAMA_PREFILL_ROWS=1` restores single-row CUDA prompt arithmetic and scheduling. Metal's native host remains at single-row prefill, and its engine's non-probe prefill is unchanged. Shared default scratch capacities are smaller on both backends. -+ -+## Memory and synchronization -+ -+The default bounds the per-lane scratch expansion rather than allocating an entire context's prompt workspace. At context 8192, CUDA's row-dependent buffers in `Engine.allocState` contribute 290832 bytes per row, excluding routing-plan storage. With 128 attention query rows, score storage is 67174400 bytes, and row buffers plus scores total 104400896 bytes per lane. At capacity 1024 the same subtotal is 835207168 bytes. These are allocation formulas, not device measurements; weights, KV, recurrent state, routing plans, codec storage, alignment and library workspaces are additional. -+ -+`setPos` still synchronizes its asynchronous upload before its stack-backed scalar expires. It is called for each prompt chunk, not each row within that chunk. Attention may also change position between its bounded query subchunks. The host's completion wait remains at the round boundary. No asynchronous upload lifetime or completion guarantee is removed. -+ -+## Arithmetic contract -+ -+Chunk size is an arithmetic setting, not a promise of identical hidden bits across sizes. -+ -+- Dense projections in `cuda/kernels.zig:linear` use `d3_gemv` for one row and cuBLAS for multiple rows. The former accumulates `dot8` groups per lane and performs a warp reduction; cuBLAS chooses a tiled reduction over the same input dimension. Both consume BF16 inputs and produce BF16 outputs with FP32 accumulation, but their grouping and rounding can differ. This affects backbone attention QKV/output, GDN input/output, router/shared-expert projections, and tower embedding/hidden mixing, QKV/output and feed-forward projections. The sampled-token head remains a one-row GEMV. -+- Full attention in `cuda/forward.zig:attention` uses split-key online softmax for one row. Its QK dot products, running softmax denominator and weighted-value sums are merged across warps and key splits. Multiple rows use BF16 QK GEMM into FP32 scores, a block-reduced causal softmax, and FP32 PV GEMM. These regroup the QK, exponential-denominator and weighted-value sums. The retained causal keys are unchanged, but GEMM dimensions and reductions can change with query chunk size and the final partial chunk. Both paths round the attention result and sigmoid gate to BF16 before their product. `cuda/blas.zig` requests FP32 compute, not TF32. -+- Routed FP8 experts switch from decode tiles to prompt tiles above two rows. `drama3_fp8.cu` preserves the per-output MMA order inside each input scale group and the ordered FP32 `fmaf` accumulation of scaled groups. The tile changes routing and staging, not that output's sum. Per-row quantization and router reductions remain unchanged for identical inputs. -+- The causal convolution and GDN recurrence retain their sequential row order and FP32 state arithmetic. Their inputs can nevertheless differ after a changed dense projection. Row-local normalization, rotary preparation and embedding operations do not introduce a cross-row sum. -+ -+Consequently, code selection near a logit tie can differ across capacities. Do not use exact cross-capacity frames or PCM as the fidelity gate. Keep the chosen capacity fixed for a request and record it in receipts. No GPU kernel implementation is changed by this patch. -+ -+## Qualification -+ -+`--check` reads upstream PyTorch oracle `vectors.safetensors`, including prompt layers, tower layers, decoded hidden states and teacher-forced picks. Prompt probes now honor `--max-rows` and use each chunk's actual reference-row offset instead of the reference tail. The existing cosine and greedy-reference checks remain in force. The extra `PREFILL` records carry worst-row cosine and maximum absolute error for each tensor chunk. -+ -+Given an existing checkpoint and oracle dump, run both modes against exactly the same vectors: -+ -+```sh -+"$BIN" --model "$CKPT" --check "$VECTORS" --max-rows 1 2>"$OUT/check-1.log" -+"$BIN" --model "$CKPT" --check "$VECTORS" --max-rows 128 2>"$OUT/check-128.log" -+python3 tools/zig/drama_prefill_compare.py "$OUT/check-1.log" "$OUT/check-128.log" -+``` -+ -+The comparator requires complete, contiguous coverage of every backbone and tower prompt tensor, both upstream checks passing, no lower worst-row cosine, and no larger maximum absolute error for any tensor. It reports both measurements, without inventing a tolerance for cross-capacity differences. The full oracle-only tower probe still requires scratch for the vector's entire prompt; oracle vectors exceeding the checked allocation capacity are refused. -+ -+Run `--replay 1` at each selected capacity to retain the existing exact eager/graph, poison/reset, codec chunking and interleaved-lane checks. Repeat a fixed-seed request with the same capacity and require identical frame and PCM bytes. Compare capacities 64, 128 and the default explicitly; record whether their outputs differ. The default and explicit 128 must have the same partition. -+ -+For free-running audio, compare stop reason and length, then word error rate and speaker similarity on the same text and reference. Keep checkpoint and evaluation-model revisions, text normalization and thresholds fixed before the run. Report cross-capacity frame/PCM equality as information, not a rejection criterion. Performance qualification includes first-audio latency, decode rate, peak physical memory and device allocation high-water marks. A host-only test pass does not establish these GPU outcomes. -+ -+Host coverage: -+ -+```sh -+CUDA_VISIBLE_DEVICES= zig build test test-kernel-contract test-golden test-cuda-host check-drama-cuda-host -+python3 -m unittest discover -s tools/zig -p test_drama_prefill_compare.py -+``` -diff --git a/tools/zig/drama_prefill_compare.py b/tools/zig/drama_prefill_compare.py -new file mode 100644 -index 00000000..c08fd141 ---- /dev/null -+++ b/tools/zig/drama_prefill_compare.py -@@ -0,0 +1,68 @@ -+"""Compare chunked Drama prompt probes against the same oracle, without requiring cross-chunk token equality.""" -+import argparse -+import json -+import math -+from pathlib import Path -+import re -+ -+ -+METRIC = re.compile(r"^PREFILL (\S+) start=(\d+) rows=(\d+) worst_cos=(\S+) max_abs=(\S+)$") -+EXPECTED = ({"backbone_input_embeds", "backbone_final_norm"} -+ | {f"backbone_hidden.layer{i:02}" for i in range(40)} -+ | {f"tower_prefill.layer{i:02}" for i in range(13)}) -+ -+ -+def collect(text): -+ metrics = {} -+ for line in text.splitlines(): -+ if not line.startswith("PREFILL "): -+ continue -+ match = METRIC.fullmatch(line) -+ if not match: -+ raise ValueError("malformed prefill metric") -+ name, start, rows, cosine, error = match.groups() -+ start, rows = int(start), int(rows) -+ cosine, error = float(cosine), float(error) -+ if not math.isfinite(cosine) or not math.isfinite(error) or error < 0 or rows == 0: -+ raise ValueError("invalid prefill metric") -+ item = metrics.setdefault(name, {"rows": 0, "worst_cos": 1.0, "max_abs": 0.0}) -+ if start != item["rows"]: -+ raise ValueError("prompt probe gap or overlap") -+ item["rows"] += rows -+ item["worst_cos"] = min(item["worst_cos"], cosine) -+ item["max_abs"] = max(item["max_abs"], error) -+ if metrics.keys() != EXPECTED: -+ raise ValueError("incomplete prompt probes") -+ n = metrics["backbone_input_embeds"]["rows"] -+ for name, item in metrics.items(): -+ if item["rows"] != n - int(name.startswith("tower_prefill.")): -+ raise ValueError("incomplete prompt rows") -+ if "RESULT drama3 check PASS:" not in text: -+ raise ValueError("upstream reference check did not pass") -+ return metrics -+ -+ -+def compare(baseline, candidate): -+ before, after = collect(baseline), collect(candidate) -+ results = {} -+ for name in sorted(EXPECTED): -+ a, b = before[name], after[name] -+ if a["rows"] != b["rows"]: -+ raise ValueError("different prompt vectors") -+ results[name] = {"one_row": a, "chunked": b, -+ "pass": b["worst_cos"] >= a["worst_cos"] and b["max_abs"] <= a["max_abs"]} -+ return {"pass": all(item["pass"] for item in results.values()), "tensors": results} -+ -+ -+def main(): -+ parser = argparse.ArgumentParser(description=__doc__) -+ parser.add_argument("one_row", type=Path) -+ parser.add_argument("chunked", type=Path) -+ args = parser.parse_args() -+ result = compare(args.one_row.read_text(), args.chunked.read_text()) -+ print(json.dumps(result, indent=2)) -+ return 0 if result["pass"] else 1 -+ -+ -+if __name__ == "__main__": -+ raise SystemExit(main()) -diff --git a/tools/zig/test_drama_prefill_compare.py b/tools/zig/test_drama_prefill_compare.py -new file mode 100644 -index 00000000..df04c98b ---- /dev/null -+++ b/tools/zig/test_drama_prefill_compare.py -@@ -0,0 +1,42 @@ -+"""Exercise the oracle comparison gate with complete, misaligned and regressed prompt metrics.""" -+import unittest -+from drama_prefill_compare import EXPECTED, collect, compare -+ -+ -+def receipt(chunk, cosine=0.999, error=1.0): -+ lines = [] -+ for name in sorted(EXPECTED): -+ count = 129 - int(name.startswith("tower_prefill.")) -+ for at in range(0, count, chunk): -+ rows = min(chunk, count - at) -+ lines.append(f"PREFILL {name} start={at} rows={rows} worst_cos={cosine} max_abs={error}") -+ return "\n".join(lines) + "\nRESULT drama3 check PASS:\n" -+ -+ -+class PrefillTests(unittest.TestCase): -+ def test_chunk_boundaries_do_not_change_coverage(self): -+ for chunk in (2, 64, 128): -+ self.assertTrue(compare(receipt(1), receipt(chunk))["pass"]) -+ -+ def test_better_reference_error_passes(self): -+ self.assertTrue(compare(receipt(1), receipt(128, 1.0, 0.0))["pass"]) -+ -+ def test_either_error_regression_fails(self): -+ self.assertFalse(compare(receipt(1), receipt(128, 0.99))["pass"]) -+ self.assertFalse(compare(receipt(1), receipt(128, error=2.0))["pass"]) -+ -+ def test_missing_or_misaligned_rows_fail(self): -+ for text in ("", receipt(128).replace("start=128", "start=127"), -+ receipt(128).replace("rows=1 ", "rows=2 ")): -+ with self.assertRaises(ValueError): -+ collect(text) -+ -+ def test_nonfinite_and_failed_reference_checks_fail(self): -+ for text in (receipt(128, float("nan")), receipt(128, error=float("inf")), -+ receipt(128).replace("check PASS", "check FAIL")): -+ with self.assertRaises(ValueError): -+ collect(text) -+ -+ -+if __name__ == "__main__": -+ unittest.main() -diff --git a/zig/src/families/drama3/check.zig b/zig/src/families/drama3/check.zig -index 1bd7916a..95807cc3 100644 ---- a/zig/src/families/drama3/check.zig -+++ b/zig/src/families/drama3/check.zig -@@ -15,16 +15,15 @@ const Check = struct { - worst: f64 = 1.0, - n: usize = 0, - fails: usize = 0, -+ prompt_at: ?usize = null, - // frame 0's heads (0: the candidate logits, 1 + i: codebook head i): both sides' top two - tops: [10]?[2]Top2 = @splat(null), - - fn refF32(c: *Check, name: []const u8, rows: usize, width: usize) !?[]f32 { - const t = c.ref.get(name) orelse return null; - const n = t.numel(); -- // the oracle's decode0 rows are [width]; prompt rows [rows, width]: compare the last `rows * width` values -- if (n < rows * width) return null; -+ const skip = try d3.common.referenceOffset(n, rows, width, c.prompt_at); - const out = try c.gpa.alloc(f32, rows * width); -- const skip = n - rows * width; - switch (t.dtype) { - .bf16 => { - const v = std.mem.bytesAsSlice(u16, @as([]align(1) const u8, t.bytes)); -@@ -116,6 +115,11 @@ const Check = struct { - const ok = worst_row >= 0.999; - if (!ok) c.fails += 1; - std.debug.print(" {s:<36} rows {d:>4} cos {d:.6} worst-row {d:.6} (row {d}) max|d| {d:.4} (max|ref| {d:.2}) {s}\n", .{ name, rows, cos, worst_row, wr, maxd, maxr, if (ok) "ok" else "LOW" }); -+ if (c.prompt_at) |at| { -+ std.debug.print("PREFILL {s} start={d} rows={d} worst_cos={e} max_abs={e}\n", .{ -+ name, at, rows, worst_row, maxd, -+ }); -+ } - } - }; - -@@ -133,18 +137,30 @@ pub fn check(gpa: std.mem.Allocator, io: std.Io, a: Args, dir: []const u8) !u8 { - x.* = @intCast(v[j * W + c]); - }; - var opts = a.opts; -+ try opts.validatePrefill(); - opts.max_rows = @max(opts.max_rows, n); - const e = try d3.Engine.create(gpa, io, a.model, opts); - defer e.destroy(); - var c: Check = .{ .gpa = gpa, .e = e, .ref = &ref }; - const probe: d3.engine.Probe = .{ .ctx = &c, .f = Check.probe }; - std.debug.print("drama3 check: {d} prompt rows from {s}\n", .{ n, vpath }); -- try e.prefill(rows, probe); -+ try e.startPrompt(rows); -+ var at: usize = 0; -+ while (at < n) { -+ const count = a.opts.promptRows(n - at); -+ c.prompt_at = at; -+ try e.promptChunk(n, at, count, probe); -+ at += count; -+ } -+ c.prompt_at = null; -+ try e.finishPrompt(rows); - if (ref.get("backbone_final_norm")) |hf| if (hf.dtype == .bf16 and hf.dim(0) == n) { - std.debug.print(" -- the tower prefill again from the oracle's backbone_final_norm rows:\n", .{}); - try e.towerOnly(n, hf.bytes, probe); -+ e.o.max_rows = a.opts.max_rows; - try e.prefill(rows, null); // restore my own state for the decode comparison - }; -+ e.o.max_rows = a.opts.max_rows; - // frame 0 with the probe (tower_decode0.*), then greedy frames against the oracle's - const smp: d3.Sampling = .{ .temperature = 0, .top_k = 1 }; - const oref = ref.get("frames_all") orelse ref.get("frames") orelse return error.NoFrames; -diff --git a/zig/src/families/drama3/common.zig b/zig/src/families/drama3/common.zig -index 7429a615..b432bc51 100644 ---- a/zig/src/families/drama3/common.zig -+++ b/zig/src/families/drama3/common.zig -@@ -4,13 +4,22 @@ const std = @import("std"); - pub const Options = struct { - device: u32 = 0, - max_ctx: usize = 4096, // positions (prompt rows + frames) -- max_rows: usize = 1024, // prompt rows a chunk -- attn_chunk: usize = 512, // query rows a prompt attention GEMM -+ max_rows: usize = 128, // prompt rows a chunk -+ attn_chunk: usize = 128, // query rows a prompt attention GEMM - max_frames: usize = 2048, - threads: usize = 16, - pdl: bool = true, // CUDA: GEMVs launched with programmatic dependent launch - warmup: bool = true, // a short prompt and frame at open: kernels, heuristics and the frame step ready - split: bool = true, // CUDA: tower codes 2..9 on a second stream beside the next backbone row -+ -+ pub fn validatePrefill(o: Options) !void { -+ if (o.max_rows == 0 or o.max_rows > 8192) return error.BadPrefillRows; -+ if (o.attn_chunk == 0 or o.attn_chunk > 1024) return error.BadAttentionChunk; -+ } -+ -+ pub fn promptRows(o: Options, remaining: usize) usize { -+ return @min(o.max_rows, remaining); -+ } - }; - - pub const Sampling = struct { -@@ -62,3 +71,56 @@ pub fn nowNs(io: std.Io) i128 { - pub fn ms(a: i128, b: i128) f64 { - return @as(f64, @floatFromInt(b - a)) / 1e6; - } -+ -+pub fn referenceOffset(total: usize, rows: usize, width: usize, at: ?usize) !usize { -+ if (width == 0 or total % width != 0 or rows > total / width) return error.BadProbeRows; -+ const last = total / width - rows; -+ const first = at orelse last; -+ if (first > last) return error.BadProbeRows; -+ return first * width; -+} -+ -+test "prefill capacity rejects empty and oversized chunks" { -+ const t = std.testing; -+ try (Options{}).validatePrefill(); -+ for ([_]usize{ 1, 128, 1024, 8192 }) |rows| try (Options{ .max_rows = rows }).validatePrefill(); -+ for ([_]usize{ 0, 8193, std.math.maxInt(usize) }) |rows| { -+ try t.expectError(error.BadPrefillRows, (Options{ .max_rows = rows }).validatePrefill()); -+ } -+ for ([_]usize{ 0, 1025, std.math.maxInt(usize) }) |rows| { -+ try t.expectError(error.BadAttentionChunk, (Options{ .attn_chunk = rows }).validatePrefill()); -+ } -+} -+ -+test "prefill rounds cover the prompt and its partial tail" { -+ const t = std.testing; -+ const defaults: Options = .{}; -+ try t.expectEqual(@as(usize, 128), defaults.max_rows); -+ for ([_]usize{ 1, 2, 128, 1024 }) |capacity| { -+ const o: Options = .{ .max_rows = capacity }; -+ for ([_]usize{ 2, 127, 128, 129, 1025, 8192 }) |n| { -+ var at: usize = 0; -+ var rounds: usize = 0; -+ while (at < n) { -+ const count = o.promptRows(n - at); -+ try t.expect(count > 0 and count <= capacity and count <= n - at); -+ at += count; -+ rounds += 1; -+ } -+ try t.expectEqual(n, at); -+ try t.expectEqual((n + capacity - 1) / capacity, rounds); -+ try t.expectEqual(@as(usize, 0), o.promptRows(n - at)); -+ } -+ } -+} -+ -+test "prompt probes select matching rows instead of the reference tail" { -+ const t = std.testing; -+ try t.expectEqual(@as(usize, 0), try referenceOffset(128 * 16, 1, 16, 0)); -+ try t.expectEqual(@as(usize, 64 * 16), try referenceOffset(128 * 16, 64, 16, 64)); -+ try t.expectEqual(@as(usize, 127 * 16), try referenceOffset(128 * 16, 1, 16, null)); -+ try t.expectError(error.BadProbeRows, referenceOffset(128 * 16, 2, 16, 127)); -+ try t.expectError(error.BadProbeRows, referenceOffset(128 * 16, 129, 16, 0)); -+ try t.expectError(error.BadProbeRows, referenceOffset(128 * 16, 1, 0, 0)); -+ try t.expectError(error.BadProbeRows, referenceOffset(128 * 16, 1, 16, std.math.maxInt(usize))); -+} -diff --git a/zig/src/families/drama3/cuda/engine.zig b/zig/src/families/drama3/cuda/engine.zig -index b71c4b02..1c16ce14 100644 ---- a/zig/src/families/drama3/cuda/engine.zig -+++ b/zig/src/families/drama3/cuda/engine.zig -@@ -102,6 +102,7 @@ pub const Engine = struct { - prompt_rows: usize = 0, - - pub fn create(gpa: std.mem.Allocator, io: std.Io, model_dir: []const u8, o: Options) !*Engine { -+ try o.validatePrefill(); - const e = try gpa.create(Engine); - errdefer gpa.destroy(e); - e.* = undefined; -@@ -349,10 +350,10 @@ pub const Engine = struct { - /// Run the prompt rows (int32 [n][11]) through the backbone and the tower, leaving the frame step ready. - pub fn prefill(e: *Engine, rows: []const [W]i32, probe: ?Probe) !void { - try e.startPrompt(rows); -- const R = if (probe != null) e.o.max_rows else 1; -+ const R = e.o.max_rows; - if (probe != null and rows.len > R) return error.ProbeNeedsOneChunk; - var at: usize = 0; -- while (at < rows.len) : (at += R) try e.promptChunk(rows.len, at, @min(R, rows.len - at), probe); -+ while (at < rows.len) : (at += R) try e.promptChunk(rows.len, at, e.o.promptRows(rows.len - at), probe); - try e.finishPrompt(rows); - } - -diff --git a/zig/src/families/drama3/drama3.zig b/zig/src/families/drama3/drama3.zig -index 31153a96..227c8357 100644 ---- a/zig/src/families/drama3/drama3.zig -+++ b/zig/src/families/drama3/drama3.zig -@@ -22,6 +22,7 @@ pub const Sampling = engine.Sampling; - pub const Options = engine.Options; - - test { -+ _ = common; - _ = completion; - _ = speech_decode; - _ = @import("replay_test.zig"); -diff --git a/zig/src/families/drama3/generate.zig b/zig/src/families/drama3/generate.zig -index 6e2ada56..00c0d302 100644 ---- a/zig/src/families/drama3/generate.zig -+++ b/zig/src/families/drama3/generate.zig -@@ -44,12 +44,13 @@ const usage = - \\ drama3-generate --model DIR --text "..." [--ref-text "..." --ref-codes FILE] [--rows FILE] - \\ [--temperature 1.0] [--top-k 30] [--top-p 0.9] [--seed 1] [--greedy] - \\ [--max-frames 1500] [--graphs 1] [--sync-every 8] [--out FILE] [--rows-out FILE] -- \\ [--max-ctx 4096] [--max-rows 1024] [--threads 16] [--repeat N] [--pdl 1] [--split 1] -+ \\ [--max-ctx 4096] [--max-rows 128] [--threads 16] [--repeat N] [--pdl 1] [--split 1] - \\ drama3-generate --model DIR --check DUMPDIR [--check-frames 40] - \\ drama3-generate --codec-check REF --codec CODEC --frames FRAMES [--ref-audio WAV] - \\ - \\WAV out: 16-bit PCM mono 44.1 kHz; --pcm-out streams raw s16le ("-" is stdout). Reference WAVs: any PCM or float32. - \\--replay 1 checks eager/graphs, scratch poisoning, codec chunks and interleaved independent state. -+ \\--check honors --max-rows for prompt probes; compare the same oracle vectors at 1 and N rows. - \\--speed-probe 1 compares the staged CUDA GEMV candidate; it never promotes a serving kernel. - \\--rows: prompt rows int32 [n, 11]; --ref-codes and --encode: int32 [frames, 10]; --out: frames int32 [n, 11]. - \\The last stderr line is "RESULT drama3 OK ..." with the frame count, timings and frames a second. -diff --git a/zig/src/native/drama_host.zig b/zig/src/native/drama_host.zig -index 9b5e7c5a..f998ee28 100644 ---- a/zig/src/native/drama_host.zig -+++ b/zig/src/native/drama_host.zig -@@ -19,7 +19,12 @@ const Job = struct { - state: d3.kv_state.State = .{}, - samples: usize = 0, - }; --pub const Options = struct { context: u32 = 8192, device: u32 = 0, lanes: u32 = 2 }; -+pub const Options = struct { -+ context: u32 = 8192, -+ device: u32 = 0, -+ lanes: u32 = 2, -+ prefill_rows: u32 = if (is_cuda) 128 else 1, -+}; - - const Host = struct { - gpa: std.mem.Allocator, -@@ -155,8 +160,9 @@ const Host = struct { - if (is_cuda) try s.t.c.reset(s.t.cs) else try s.t.c.reset(); - j.state.started = true; - } -- try s.e.promptChunk(r.frames.len, j.state.prompt, 1, null); -- j.state.prompt += 1; -+ const count = s.e.o.promptRows(r.frames.len - j.state.prompt); -+ try s.e.promptChunk(r.frames.len, j.state.prompt, count, null); -+ j.state.prompt += count; - if (j.state.prompt == r.frames.len) { - try s.e.finishPrompt(r.frames); - h.lock(); -@@ -241,6 +247,7 @@ const Host = struct { - - pub fn open(gpa: std.mem.Allocator, io: std.Io, dir: []const u8, _: []const u8, o: Options) !api.Opened { - if (o.context < 1025 or o.context > 8192) return error.BadDramaContext; -+ try (d3.Options{ .max_rows = o.prefill_rows, .attn_chunk = o.prefill_rows }).validatePrefill(); - const h = try gpa.create(Host); - errdefer gpa.destroy(h); - h.* = .{ .gpa = gpa, .io = io, .limit = undefined, .queue = try rounds.Queue.init(o.lanes), .info_ = .{} }; -@@ -257,8 +264,8 @@ pub fn open(gpa: std.mem.Allocator, io: std.Io, dir: []const u8, _: []const u8, - while (made < o.lanes) : (made += 1) { - const e = if (made == 0) try d3.Engine.create(gpa, io, dir, .{ - .max_ctx = o.context, -- .max_rows = 1, -- .attn_chunk = 1, -+ .max_rows = o.prefill_rows, -+ .attn_chunk = o.prefill_rows, - .max_frames = 1024, - .warmup = false, - .device = o.device, -diff --git a/zig/src/native/drama_open.zig b/zig/src/native/drama_open.zig -index 10db5e2e..c4f27635 100644 ---- a/zig/src/native/drama_open.zig -+++ b/zig/src/native/drama_open.zig -@@ -24,6 +24,11 @@ pub fn open(a: Allocator, gpa: Allocator, io: std.Io, o: api.Open, problem: *[]c - return null; - } - const device = try setting(a, o.device, "TF_CUDA_DEVICE", 0, problem) orelse return null; -+ const prefill_rows = try setting(a, null, "TF_DRAMA_PREFILL_ROWS", 128, problem) orelse return null; -+ if (prefill_rows == 0 or prefill_rows > 1024) { -+ problem.* = "TF_DRAMA_PREFILL_ROWS must be 1 through 1024"; -+ return null; -+ } - const segments = try setting(a, o.segments, "TF_CUDA_SEGMENTS", 1, problem) orelse return null; - if (segments != 1) { - problem.* = "Drama 3 uses one prompt segment at a time: use --segments 1"; -@@ -33,7 +38,12 @@ pub fn open(a: Allocator, gpa: Allocator, io: std.Io, o: api.Open, problem: *[]c - problem.* = "Drama 3 uses embedded native kernels, not captured kernel sets: unset TENSORFOLD_CUDA_KERNELS or set it to native"; - return null; - }; -- return drama_host.open(gpa, io, o.dir, "", .{ .context = @intCast(window), .device = device, .lanes = if (o.lanes_fixed) o.lanes else 2 }) catch |e| { -+ return drama_host.open(gpa, io, o.dir, "", .{ -+ .context = @intCast(window), -+ .device = device, -+ .lanes = if (o.lanes_fixed) o.lanes else 2, -+ .prefill_rows = prefill_rows, -+ }) catch |e| { - problem.* = try std.fmt.allocPrint(a, "the native Drama 3 CUDA engine could not load the checkpoint ({s})", .{@errorName(e)}); - return null; - }; diff --git a/engine/patches/0002-fix-drama3-complete-replay-poison-before-resetting-s.patch b/engine/patches/0002-fix-drama3-complete-replay-poison-before-resetting-s.patch deleted file mode 100644 index 26e398f..0000000 --- a/engine/patches/0002-fix-drama3-complete-replay-poison-before-resetting-s.patch +++ /dev/null @@ -1,94 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: plotarmordev <299844489+plotarmordev@users.noreply.github.com> -Date: Tue, 20 Oct 2026 23:39:33 +0800 -Subject: [PATCH 02/11] fix(drama3): complete replay poison before resetting - streams - ---- - docs/drama3-prefill.md | 2 ++ - zig/src/families/drama3/cuda/codec.zig | 2 ++ - zig/src/families/drama3/cuda/engine.zig | 2 ++ - zig/src/families/drama3/cuda/owner_test.zig | 31 +++++++++++++++++++++ - 4 files changed, 37 insertions(+) - -diff --git a/docs/drama3-prefill.md b/docs/drama3-prefill.md -index 8a6c7803..60aca66e 100644 ---- a/docs/drama3-prefill.md -+++ b/docs/drama3-prefill.md -@@ -39,6 +39,8 @@ The comparator requires complete, contiguous coverage of every backbone and towe - - Run `--replay 1` at each selected capacity to retain the existing exact eager/graph, poison/reset, codec chunking and interleaved-lane checks. Repeat a fixed-seed request with the same capacity and require identical frame and PCM bytes. Compare capacities 64, 128 and the default explicitly; record whether their outputs differ. The default and explicit 128 must have the same partition. - -+CUDA replay poisoning waits for its default-stream fills before prompt reset or codec reset can reuse the buffers. A host-returning device memset does not order work on the engines' non-blocking streams; without this wait, poisoning can overwrite the restored attention position and cause an out-of-bounds KV write. This ordering requirement also applies to single-row prefill. -+ - For free-running audio, compare stop reason and length, then word error rate and speaker similarity on the same text and reference. Keep checkpoint and evaluation-model revisions, text normalization and thresholds fixed before the run. Report cross-capacity frame/PCM equality as information, not a rejection criterion. Performance qualification includes first-audio latency, decode rate, peak physical memory and device allocation high-water marks. A host-only test pass does not establish these GPU outcomes. - - Host coverage: -diff --git a/zig/src/families/drama3/cuda/codec.zig b/zig/src/families/drama3/cuda/codec.zig -index f5468948..9f874329 100644 ---- a/zig/src/families/drama3/cuda/codec.zig -+++ b/zig/src/families/drama3/cuda/codec.zig -@@ -202,6 +202,8 @@ pub const Codec = struct { - - pub fn poison(c: *Codec) !void { - for (c.bufs.items[c.state_start..c.state_end]) |*buffer| try buffer.fill8(0xa5, null); -+ // Reset and decode use non-blocking streams, so the default-stream fills must finish first. -+ try c.d.synchronize(); - } - - pub fn destroy(c: *Codec) void { -diff --git a/zig/src/families/drama3/cuda/engine.zig b/zig/src/families/drama3/cuda/engine.zig -index 1c16ce14..c0daff72 100644 ---- a/zig/src/families/drama3/cuda/engine.zig -+++ b/zig/src/families/drama3/cuda/engine.zig -@@ -215,6 +215,8 @@ pub const Engine = struct { - try e.stream.synchronize(); - try e.stream2.synchronize(); - for (e.bufs.items) |*buffer| try buffer.fill8(0xa5, null); -+ // cuMemsetD8 may return before completion; non-blocking streams do not wait for it. -+ try e.driver.synchronize(); - } - - fn alloc(e: *Engine, bytes: usize) !u64 { -diff --git a/zig/src/families/drama3/cuda/owner_test.zig b/zig/src/families/drama3/cuda/owner_test.zig -index 7918b0b9..d955d06a 100644 ---- a/zig/src/families/drama3/cuda/owner_test.zig -+++ b/zig/src/families/drama3/cuda/owner_test.zig -@@ -126,6 +126,37 @@ const Mock = struct { - return error.InjectedWarmFailure; - } - }; -+test "drama poison completes default-stream fills before reset can reuse buffers" { -+ const Fill = struct { -+ var pending: bool = false; -+ fn fill(_: u64, value: u8, _: usize) callconv(.c) c_int { -+ std.debug.assert(value == 0xa5); -+ pending = true; -+ return 0; -+ } -+ fn finish() callconv(.c) c_int { -+ pending = false; -+ return 0; -+ } -+ }; -+ var e: Engine = undefined; -+ Mock.init(&e); -+ e.driver.api.cuMemsetD8_v2 = Fill.fill; -+ e.driver.api.cuCtxSynchronize = Fill.finish; -+ e.bufs = .empty; -+ defer e.bufs.deinit(a); -+ try e.bufs.append(a, .{ .d = &e.driver, .ptr = 1, .len = 256, .borrowed = true }); -+ try e.poison(); -+ try std.testing.expect(!Fill.pending); -+ var c: Codec = undefined; -+ c.d = &e.driver; -+ c.bufs = e.bufs; -+ c.state_start = 0; -+ c.state_end = c.bufs.items.len; -+ try c.poison(); -+ try std.testing.expect(!Fill.pending); -+} -+ - test "drama owner: failed fork teardown pins shared codec buffers descriptors and module through base teardown" { - var e: Engine = undefined; - Mock.init(&e); diff --git a/engine/patches/0003-feat-speech-configure-the-Drama-frame-budget.patch b/engine/patches/0003-feat-speech-configure-the-Drama-frame-budget.patch deleted file mode 100644 index e3a70ab..0000000 --- a/engine/patches/0003-feat-speech-configure-the-Drama-frame-budget.patch +++ /dev/null @@ -1,272 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: plotarmordev <299844489+plotarmordev@users.noreply.github.com> -Date: Tue, 20 Oct 2026 14:08:05 +0800 -Subject: [PATCH 03/11] feat(speech): configure the Drama frame budget - -K1: feat/max-frames 742e4d83, upstream #1134. ---- - CHANGELOG.md | 1 + - zig/build/drama3_host.zig | 5 ++++ - zig/src/core/engine_api.zig | 1 + - zig/src/native/drama_budget.zig | 43 +++++++++++++++++++++++++++++++++ - zig/src/native/drama_host.zig | 8 +++--- - zig/src/native/drama_open.zig | 20 ++++++++++++++- - zig/src/server/cli.zig | 9 +++++++ - zig/src/server/cli_test.zig | 20 +++++++++++++++ - 8 files changed, 103 insertions(+), 4 deletions(-) - create mode 100644 zig/src/native/drama_budget.zig - -diff --git a/CHANGELOG.md b/CHANGELOG.md -index 975aaee8..26542a87 100644 ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -5,6 +5,7 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - ## Unreleased - - - Drama 3 on CUDA prefills bounded row chunks in each shared round. `TF_DRAMA_PREFILL_ROWS` selects 1 through 1024 rows (default 128); the generation tool uses `--max-rows`. Batched projections and attention can change rounding relative to single-row prefill; oracle checks now compare the selected prompt chunks at their reference-row offsets. -+- Drama 3 serving accepts `--max-frames` to bound synthesis frames, preserving the stock cap when omitted. - - ## 1.1.1 - -diff --git a/zig/build/drama3_host.zig b/zig/build/drama3_host.zig -index a104a69b..1da5c9ad 100644 ---- a/zig/build/drama3_host.zig -+++ b/zig/build/drama3_host.zig -@@ -49,6 +49,11 @@ pub fn module(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.bui - - /// Drama's CUDA-side host steps (its tests, the owners' filter, the CUDA entry points compiled); returns its module. - pub fn hostTests(b: *std.Build, step: *std.Build.Step, cuda: *std.Build.Module, core: *std.Build.Module) *std.Build.Module { -+ const budget = b.addRunArtifact(b.addTest(.{ .root_module = b.createModule(.{ -+ .root_source_file = b.path("zig/src/native/drama_budget.zig"), -+ .target = b.graph.host, -+ }) })); -+ step.dependOn(&budget.step); - const drama3 = module(b, b.graph.host, .debug, cuda, core); - compile(b, b.step("check-drama-cuda-host", "Compile Drama CUDA GPU entry points without running them"), drama3, "drama_cuda_host"); - b.step("test-drama-owner", "Drama's real Tts and Codec owners with mocked CUDA waits").dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = drama3, .filters = &.{"drama owner:"} })).step); -diff --git a/zig/src/core/engine_api.zig b/zig/src/core/engine_api.zig -index 7a8bfd4e..5be157d7 100644 ---- a/zig/src/core/engine_api.zig -+++ b/zig/src/core/engine_api.zig -@@ -240,6 +240,7 @@ pub const Open = struct { - dir: []const u8, - model_type: []const u8, - context: ?i64 = null, -+ max_frames: ?u32 = null, - lanes: u32 = 8, - /// --parallel named a number: an engine whose memory fits fewer streams refuses instead of serving fewer. - lanes_fixed: bool = false, -diff --git a/zig/src/native/drama_budget.zig b/zig/src/native/drama_budget.zig -new file mode 100644 -index 00000000..b27256ad ---- /dev/null -+++ b/zig/src/native/drama_budget.zig -@@ -0,0 +1,43 @@ -+const std = @import("std"); -+ -+pub fn budget(context: u32, max_frames: ?u32) !struct { frames: u32, prompt: u32 } { -+ if (context < 1025 or context > 8192) return error.BadDramaContext; -+ if (max_frames) |n| { -+ if (n < 1 or n > 8190) return error.BadDramaMaxFrames; -+ if (n >= context - 1) return error.NoDramaPromptRoom; -+ } -+ // Keep the stock low-context clamp when the option is omitted. -+ const frames = @min(max_frames orelse 1024, context - 3); -+ return .{ .frames = frames, .prompt = @min(4096, context - frames - 1) }; -+} -+ -+test "default frame and prompt budgets match stock at every supported context" { -+ for (1025..8193) |ctx| { -+ const context: u32 = @intCast(ctx); -+ const got = try budget(context, null); -+ const stock = @min(1024, context - 3); -+ try std.testing.expectEqual(stock, got.frames); -+ try std.testing.expectEqual(@min(4096, context - stock - 1), got.prompt); -+ } -+} -+ -+test "explicit frame budgets are bounded and leave prompt room" { -+ const long = try budget(8192, 4096); -+ try std.testing.expectEqual(@as(u32, 4096), long.frames); -+ try std.testing.expectEqual(@as(u32, 4095), long.prompt); -+ const small = try budget(8192, 1); -+ try std.testing.expectEqual(@as(u32, 1), small.frames); -+ try std.testing.expectEqual(@as(u32, 4096), small.prompt); -+ const edge = try budget(8192, 8190); -+ try std.testing.expectEqual(@as(u32, 8189), edge.frames); -+ try std.testing.expectEqual(@as(u32, 2), edge.prompt); -+ try std.testing.expectError(error.BadDramaMaxFrames, budget(8192, 0)); -+ try std.testing.expectError(error.BadDramaMaxFrames, budget(8192, 8191)); -+ try std.testing.expectError(error.NoDramaPromptRoom, budget(4097, 4096)); -+ try std.testing.expectError(error.NoDramaPromptRoom, budget(1025, 1024)); -+ try std.testing.expectError(error.BadDramaContext, budget(1024, null)); -+ try std.testing.expectError(error.BadDramaContext, budget(8193, null)); -+ const explicit = try budget(8192, 1024); -+ const default = try budget(8192, null); -+ try std.testing.expectEqualDeep(default, explicit); -+} -diff --git a/zig/src/native/drama_host.zig b/zig/src/native/drama_host.zig -index f998ee28..7a415d26 100644 ---- a/zig/src/native/drama_host.zig -+++ b/zig/src/native/drama_host.zig -@@ -24,6 +24,7 @@ pub const Options = struct { - device: u32 = 0, - lanes: u32 = 2, - prefill_rows: u32 = if (is_cuda) 128 else 1, -+ max_frames: ?u32 = null, - }; - - const Host = struct { -@@ -248,6 +249,7 @@ const Host = struct { - pub fn open(gpa: std.mem.Allocator, io: std.Io, dir: []const u8, _: []const u8, o: Options) !api.Opened { - if (o.context < 1025 or o.context > 8192) return error.BadDramaContext; - try (d3.Options{ .max_rows = o.prefill_rows, .attn_chunk = o.prefill_rows }).validatePrefill(); -+ const budget = try @import("drama_budget.zig").budget(o.context, o.max_frames); - const h = try gpa.create(Host); - errdefer gpa.destroy(h); - h.* = .{ .gpa = gpa, .io = io, .limit = undefined, .queue = try rounds.Queue.init(o.lanes), .info_ = .{} }; -@@ -266,7 +268,7 @@ pub fn open(gpa: std.mem.Allocator, io: std.Io, dir: []const u8, _: []const u8, - .max_ctx = o.context, - .max_rows = o.prefill_rows, - .attn_chunk = o.prefill_rows, -- .max_frames = 1024, -+ .max_frames = o.max_frames orelse 1024, - .warmup = false, - .device = o.device, - }) else try h.sessions[0].e.fork(); -@@ -274,7 +276,7 @@ pub fn open(gpa: std.mem.Allocator, io: std.Io, dir: []const u8, _: []const u8, - const t = if (made == 0) try d3.tts.Tts.create(gpa, io, e, codec, 1) else try h.sessions[0].t.fork(e); - h.sessions[made] = .{ .e = e, .t = t }; - } -- const frame_cap = @min(1024, o.context - 3); -+ const frame_cap = budget.frames; - h.info_ = .{ - .name = "Drama 3", - .text_generation = false, -@@ -282,7 +284,7 @@ pub fn open(gpa: std.mem.Allocator, io: std.Io, dir: []const u8, _: []const u8, - .context_window = o.context, - .plain_only = true, - .prompt_cache = false, -- .speech = .{ .sample_rate = 44100, .frame_length = 2048, .max_frames = frame_cap, .max_prompt_tokens = @min(4096, o.context - frame_cap - 1) }, -+ .speech = .{ .sample_rate = 44100, .frame_length = 2048, .max_frames = frame_cap, .max_prompt_tokens = budget.prompt }, - }; - h.worker = try std.Thread.spawn(.{}, Host.run, .{h}); - return .{ .engine = .{ .ctx = h, .vtable = &Host.vtable }, .ctx = h, .close = Host.close }; -diff --git a/zig/src/native/drama_open.zig b/zig/src/native/drama_open.zig -index c4f27635..77ed79f7 100644 ---- a/zig/src/native/drama_open.zig -+++ b/zig/src/native/drama_open.zig -@@ -15,6 +15,10 @@ pub fn open(a: Allocator, gpa: Allocator, io: std.Io, o: api.Open, problem: *[]c - problem.* = "Drama 3 supports --context 1025 through 8192"; - return null; - } -+ if (frameProblem(@intCast(window), o.max_frames)) |why| { -+ problem.* = why; -+ return null; -+ } - if (o.vision != null or o.precision != null or o.record != null or o.recall or o.learn != null or - o.learn_conversations or o.speed_up != null or o.slide or o.drafter != null or o.mtp_drafts != null or o.mtp_confidence != null or - (o.prompt_cache_gib != null and o.prompt_cache_gib.? != 0) or -@@ -43,6 +47,7 @@ pub fn open(a: Allocator, gpa: Allocator, io: std.Io, o: api.Open, problem: *[]c - .device = device, - .lanes = if (o.lanes_fixed) o.lanes else 2, - .prefill_rows = prefill_rows, -+ .max_frames = o.max_frames, - }) catch |e| { - problem.* = try std.fmt.allocPrint(a, "the native Drama 3 CUDA engine could not load the checkpoint ({s})", .{@errorName(e)}); - return null; -@@ -62,8 +67,21 @@ pub fn openMetal(a: Allocator, gpa: Allocator, io: std.Io, o: api.Open, problem: - problem.* = "Drama 3 needs context 1025..8192, 1..8 frame lanes and no text, vision, TP or cache modifiers"; - return null; - } -- return drama_host.open(gpa, io, o.dir, "", .{ .context = @intCast(window), .lanes = if (o.lanes_fixed) o.lanes else 2 }) catch |err| { -+ if (frameProblem(@intCast(window), o.max_frames)) |why| { -+ problem.* = why; -+ return null; -+ } -+ return drama_host.open(gpa, io, o.dir, "", .{ .context = @intCast(window), .lanes = if (o.lanes_fixed) o.lanes else 2, .max_frames = o.max_frames }) catch |err| { - problem.* = try std.fmt.allocPrint(a, "Drama 3 Metal checkpoint load failed ({s})", .{@errorName(err)}); - return null; - }; - } -+ -+fn frameProblem(context: u32, frames: ?u32) ?[]const u8 { -+ _ = @import("drama_budget.zig").budget(context, frames) catch |err| return switch (err) { -+ error.BadDramaMaxFrames => "Drama 3 supports --max-frames 1 through 8190", -+ error.NoDramaPromptRoom => "Drama 3 --max-frames must leave prompt room: use --max-frames < --context - 1", -+ error.BadDramaContext => "Drama 3 supports --context 1025 through 8192", -+ }; -+ return null; -+} -diff --git a/zig/src/server/cli.zig b/zig/src/server/cli.zig -index 8afebaeb..05aabec0 100644 ---- a/zig/src/server/cli.zig -+++ b/zig/src/server/cli.zig -@@ -59,6 +59,7 @@ pub const flags = [_]Flag{ - .{ .name = "--vision-image-tokens", .native = true }, - .{ .name = "--vision-video-tokens", .native = true }, - .{ .name = "--context", .native = true }, -+ .{ .name = "--max-frames", .native = true }, - .{ .name = "--speed-up", .native = !cuda_build }, - .{ .name = "--max-tokens", .native = true }, - .{ .name = "--temperature", .native = true }, -@@ -147,6 +148,7 @@ pub const Args = struct { - metrics_open: bool = false, - dashboard: bool = false, - context: ?i64 = null, -+ max_frames: ?u32 = null, - speed_up: ?[]const u8 = null, // speed-up mode: this Mac's settings for the two-Mac link (Flash Next and the GLMs) - prompt_cache_gib: ?f64 = null, // kept prompt states' budget (0: none; null: what 70% of RAM leaves past the loaded server) - prompt_cache_over_cap: bool = false, // a --prompt-cache-gib past that is kept, not refused -@@ -300,6 +302,12 @@ fn apply(a: Allocator, out: *Args, name: []const u8, value: ?[]const u8, u: *Usa - out.chat_template = v; - return; - } -+ if (is(name, "--max-frames")) { -+ const n = try int(u, a, name, v); -+ if (n < 1 or n > 8190) return fail(u, a, "argument --max-frames: expected 1 through 8190: '{s}'", .{v}); -+ out.max_frames = @intCast(n); -+ return; -+ } - if (is(name, "--host")) out.host = v else if (is(name, "--port")) { - const p = try int(u, a, name, v); - if (p < 0 or p > 65535) return fail(u, a, "argument --port: invalid port: '{s}'", .{v}); -@@ -433,6 +441,7 @@ pub fn request(a: Allocator, dir: []const u8, model_type: []const u8, args: Args - .dir = dir, - .model_type = model_type, - .context = args.context, -+ .max_frames = args.max_frames, - .lanes = parallel(args.parallel) orelse 8, - .lanes_fixed = parallelFixed(args.parallel), - .drafts = !args.no_drafts, -diff --git a/zig/src/server/cli_test.zig b/zig/src/server/cli_test.zig -index 777dd72d..e85c2f3e 100644 ---- a/zig/src/server/cli_test.zig -+++ b/zig/src/server/cli_test.zig -@@ -7,6 +7,26 @@ const parse = cli.parse; - const request = cli.request; - const capabilities = cli.capabilities; - -+test "max frames CLI bounds and engine request preserve the default" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ var u: Usage = .{}; -+ const defaults = try parse(a, &.{"m"}, &u); -+ try std.testing.expectEqual(@as(?u32, null), defaults.max_frames); -+ try std.testing.expectEqual(@as(?u32, null), (try request(a, "d", "fish_omni", defaults)).max_frames); -+ for ([_][]const u8{ "1", "1024", "4096", "8190" }) |value| { -+ const args = try parse(a, &.{ "m", "--max-frames", value }, &u); -+ const expected = try std.fmt.parseInt(u32, value, 10); -+ try std.testing.expectEqual(@as(?u32, expected), args.max_frames); -+ try std.testing.expectEqual(args.max_frames, (try request(a, "d", "fish_omni", args)).max_frames); -+ } -+ for ([_][]const u8{ "0", "-1", "8191", "4294967296", "nan", "1.5" }) |value| { -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--max-frames", value }, &u)); -+ } -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--max-frames" }, &u)); -+} -+ - test "parse and capabilities share the table" { - var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer arena.deinit(); diff --git a/engine/patches/0004-feat-server-filter-speech-controls-with-an-opt-in-al.patch b/engine/patches/0004-feat-server-filter-speech-controls-with-an-opt-in-al.patch deleted file mode 100644 index 59a17ba..0000000 --- a/engine/patches/0004-feat-server-filter-speech-controls-with-an-opt-in-al.patch +++ /dev/null @@ -1,627 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: plotarmordev <299844489+plotarmordev@users.noreply.github.com> -Date: Wed, 21 Oct 2026 00:18:27 +0800 -Subject: [PATCH 04/11] feat(server): filter speech controls with an opt-in - allowlist - -K7: net diff of pr/allowed-controls aa6bdaaa (6 commits). ---- - CHANGELOG.md | 1 + - docs/speech-controls.md | 46 +++++ - zig/src/core/template/template.zig | 1 + - zig/src/core/tokenizer/tokenizer.zig | 2 +- - zig/src/server/cli.zig | 9 +- - zig/src/server/cli_test.zig | 19 ++ - zig/src/server/drama_allow.zig | 213 +++++++++++++++++++++ - zig/src/server/drama_allow_golden_test.zig | 43 +++++ - zig/src/server/drama_cues.txt | 109 +++++++++++ - zig/src/server/serve.zig | 1 + - zig/src/server/server.zig | 1 + - zig/src/server/speech.zig | 23 ++- - 12 files changed, 465 insertions(+), 3 deletions(-) - create mode 100644 docs/speech-controls.md - create mode 100644 zig/src/server/drama_allow.zig - create mode 100644 zig/src/server/drama_allow_golden_test.zig - create mode 100644 zig/src/server/drama_cues.txt - -diff --git a/CHANGELOG.md b/CHANGELOG.md -index 26542a87..b7372176 100644 ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -6,6 +6,7 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - - - Drama 3 on CUDA prefills bounded row chunks in each shared round. `TF_DRAMA_PREFILL_ROWS` selects 1 through 1024 rows (default 128); the generation tool uses `--max-rows`. Batched projections and attention can change rounding relative to single-row prefill; oracle checks now compare the selected prompt chunks at their reference-row offsets. - - Drama 3 serving accepts `--max-frames` to bound synthesis frames, preserving the stock cap when omitted. -+- `serve --allowed-controls LIST` opts into stripping unlisted bracketed speech controls before input validation and tokenization. Omission preserves the input unchanged; see [speech controls](docs/speech-controls.md) for the open-vocabulary name grammar and malformed-input policy. - - ## 1.1.1 - -diff --git a/docs/speech-controls.md b/docs/speech-controls.md -new file mode 100644 -index 00000000..2d93ccf9 ---- /dev/null -+++ b/docs/speech-controls.md -@@ -0,0 +1,46 @@ -+# Speech control allowlist -+ -+`tensorfold serve MODEL --allowed-controls 'sighs,laughs'` filters the `input` of -+`POST /v1/audio/speech` before UTF-8, empty-input, length and prompt checks, -+tokenization, and prompt-derived seeding. It does not affect chat, completions, -+or the standalone generation tool. Omitting the flag leaves input byte-identical -+and does not allocate a filtered copy. An empty list (`--allowed-controls ''`) -+strips recognized delivery controls. Names compare after Unicode NFC normalization, -+lowercasing, whitespace folding and removal of trailing `!?.…`. Empty -+comma-separated entries are ignored. Names must be valid UTF-8 without square -+brackets; novel descriptors remain accepted. -+ -+## Open vocabulary and public examples -+ -+The [official vLLM-Omni recipe](https://recipes.vllm.ai/fishaudio/drama-3) -+states that there is no fixed tag list: tags are ordinary text and any short -+descriptor is accepted. LIST therefore accepts novel descriptors such as -+`gentle robot`; it does not reject an otherwise valid name as unknown. -+ -+| Example | Public source | -+| --- | --- | -+| `sighs`, `laughs` | [Drama model card, Point tags](https://huggingface.co/fishaudio/drama-3#point-tags) | -+| `laughing`, `whisper`, `angry`, `excited`, `pause` | [vLLM-Omni recipe, Event and emotion tags](https://recipes.vllm.ai/fishaudio/drama-3) | -+ -+`drama_cues.txt` lists only words used in tags in Fish Audio's public Drama 3 -+and Emotion Control docs, the Drama 3 model card and the Drama 3 technical -+report. These examples are not an exhaustive list. -+ -+## Stripping rules -+ -+Listed square-bracket controls are retained with their normalized names. An -+unlisted bracket is removed only if every word is in `drama_cues.txt` and at least -+one word is a cue rather than a modifier. Unknown brackets, notes, nested -+contextual brackets, indexes, Markdown links, quoted syntax, unclosed brackets -+and brackets beyond the control scan bound are preserved. -+ -+The documented scoped tags `whisper`, `emphasis`, `soft`, `fast`, `slow`, -+`stress` and `forceful` are removed case-insensitively, including inside -+preserved brackets; their enclosed text stays. Other angle-bracket text stays. -+ -+Removing a control preserves the surrounding whitespace run with more line -+breaks (the preceding run wins ties), and removes leading whitespace when -+there is no preceding text. Whitespace elsewhere is unchanged. An input that -+becomes empty is rejected by the existing speech validation. -+ -+This is a lexical filter, not a general prompt-injection or content-safety policy. -diff --git a/zig/src/core/template/template.zig b/zig/src/core/template/template.zig -index fe64adb8..43942d71 100644 ---- a/zig/src/core/template/template.zig -+++ b/zig/src/core/template/template.zig -@@ -9,6 +9,7 @@ const pyjson = @import("pyjson.zig"); - const eval = @import("eval.zig"); - const filters = @import("filters.zig"); - const access = @import("access.zig"); -+pub const unicode = @import("unicode.zig"); - const Allocator = std.mem.Allocator; - - pub const Error = error{ TemplateFailed, OutOfMemory }; -diff --git a/zig/src/core/tokenizer/tokenizer.zig b/zig/src/core/tokenizer/tokenizer.zig -index 785ed87f..42a1ce3a 100644 ---- a/zig/src/core/tokenizer/tokenizer.zig -+++ b/zig/src/core/tokenizer/tokenizer.zig -@@ -1,6 +1,6 @@ - //! TensorFold's tokenizer for Hugging Face tokenizer.json files: encode equals tokenizers' encode(text, add_special_tokens=False). - const std = @import("std"); --const unicode = @import("unicode.zig"); -+pub const unicode = @import("unicode.zig"); - const steps = @import("steps.zig"); - const models = @import("model.zig"); - const Allocator = std.mem.Allocator; -diff --git a/zig/src/server/cli.zig b/zig/src/server/cli.zig -index 05aabec0..3ca82675 100644 ---- a/zig/src/server/cli.zig -+++ b/zig/src/server/cli.zig -@@ -38,11 +38,12 @@ fn servedBy(list: []const []const u8, name: []const u8) bool { - /// The CUDA build's own flags and variables (the Metal build refuses them). - const cuda_build = builtin.os.tag == .linux; - --/// Every Python serve flag (``cli_args.build_parser``), the ones this binary serves marked native. -+/// Python serve flags plus this binary's native-only flags; supported flags are marked native. - pub const flags = [_]Flag{ - .{ .name = "--host", .native = true }, - .{ .name = "--allowed-host", .kind = .append, .native = true }, - .{ .name = "--allowed-origin", .kind = .append, .native = true }, -+ .{ .name = "--allowed-controls", .native = true }, - .{ .name = "--port", .native = true }, - .{ .name = "--name", .native = true }, - .{ .name = "--chat-template", .native = true }, -@@ -144,6 +145,7 @@ pub const Args = struct { - api_key: []const []const u8 = &.{}, - allowed_host: []const []const u8 = &.{}, - allowed_origin: []const []const u8 = &.{}, -+ allowed_controls: ?@import("drama_allow.zig").Policy = null, - api_key_file: ?[]const u8 = null, - metrics_open: bool = false, - dashboard: bool = false, -@@ -288,6 +290,11 @@ fn apply(a: Allocator, out: *Args, name: []const u8, value: ?[]const u8, u: *Usa - return std.mem.eql(u8, x, y); - } - }.f; -+ if (is(name, "--allowed-controls")) { -+ out.allowed_controls = @import("drama_allow.zig").parse(v) catch -+ return fail(u, a, "argument --allowed-controls: expected comma-separated UTF-8 names without square brackets: '{s}'", .{v}); -+ return; -+ } - if (is(name, "--allowed-host")) { - if (!@import("request_guard.zig").validHost(v)) return fail(u, a, "argument --allowed-host: expected a hostname or bracketed IP without a port: '{s}'", .{v}); - return repeated.hosts.append(a, v); -diff --git a/zig/src/server/cli_test.zig b/zig/src/server/cli_test.zig -index e85c2f3e..00204ba7 100644 ---- a/zig/src/server/cli_test.zig -+++ b/zig/src/server/cli_test.zig -@@ -27,6 +27,25 @@ test "max frames CLI bounds and engine request preserve the default" { - try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--max-frames" }, &u)); - } - -+test "allowed controls CLI is opt-in and validates name syntax only" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ var u: Usage = .{}; -+ try std.testing.expect((try parse(a, &.{"m"}, &u)).allowed_controls == null); -+ const on = try parse(a, &.{ "m", "--allowed-controls", "sighs, laughs" }, &u); -+ try std.testing.expectEqualDeep(try @import("drama_allow.zig").parse("sighs, laughs"), on.allowed_controls.?); -+ const empty = try parse(a, &.{ "m", "--allowed-controls=" }, &u); -+ try std.testing.expectEqualDeep(try @import("drama_allow.zig").parse(""), empty.allowed_controls.?); -+ const open = try parse(a, &.{ "m", "--allowed-controls", "whisper,unknown,gentle robot" }, &u); -+ try std.testing.expectEqualStrings("whisper,unknown,gentle robot", open.allowed_controls.?.list); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--allowed-controls", "[whisper]" }, &u)); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--allowed-controls" }, &u)); -+ var out: std.Io.Writer.Allocating = .init(a); -+ try capabilities(&out.writer, .{ .version = "test" }); -+ try std.testing.expect(std.mem.indexOf(u8, out.written(), "\"--allowed-controls\": {}") != null); -+} -+ - test "parse and capabilities share the table" { - var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer arena.deinit(); -diff --git a/zig/src/server/drama_allow.zig b/zig/src/server/drama_allow.zig -new file mode 100644 -index 00000000..401e23a0 ---- /dev/null -+++ b/zig/src/server/drama_allow.zig -@@ -0,0 +1,213 @@ -+//! Opt-in speech control filtering; callers own the arena used for changed input. -+const std = @import("std"); -+const unicode = @import("tokenizer").unicode; -+const case = @import("template").unicode; -+const Allocator = std.mem.Allocator; -+ -+pub const Policy = struct { list: []const u8 = "" }; -+ -+// The policy borrows LIST, as the other string-valued serve flags borrow their arguments. -+pub fn parse(list: []const u8) error{InvalidControl}!Policy { -+ if (!std.unicode.utf8ValidateSlice(list) or std.mem.indexOfAny(u8, list, "[]") != null) return error.InvalidControl; -+ return .{ .list = std.mem.trim(u8, list, " \t\r\n") }; -+} -+ -+const max_control = 120; -+const scoped_tags = [_][]const u8{ "whisper", "emphasis", "soft", "fast", "slow", "stress", "forceful" }; -+const Section = enum { cues, modifiers }; -+const vocabulary = vocabulary: { -+ @setEvalBranchQuota(200_000); -+ const Entry = struct { []const u8, Section }; -+ var entries: []const Entry = &.{}; -+ var section: ?Section = null; -+ var lines = std.mem.tokenizeScalar(u8, @embedFile("drama_cues.txt"), '\n'); -+ while (lines.next()) |line| { -+ if (std.mem.startsWith(u8, line, "## ")) { -+ section = std.meta.stringToEnum(Section, line[3..]) orelse @compileError("unknown section " ++ line); -+ } else if (line[0] != '#') entries = entries ++ &[_]Entry{.{ line, section orelse @compileError("a word before any section") }}; -+ } -+ break :vocabulary std.StaticStringMap(Section).initComptime(entries); -+}; -+ -+pub fn filter(a: Allocator, policy: ?Policy, input: []const u8) Allocator.Error![]const u8 { -+ const active = policy orelse return input; -+ // The request layer reports invalid UTF-8 after filtering; normalization needs valid code points. -+ if (!std.unicode.utf8ValidateSlice(input)) return input; -+ var names: std.ArrayList([]const u8) = .empty; -+ var parts = std.mem.splitScalar(u8, active.list, ','); -+ while (parts.next()) |part| { -+ const name = try normalize(a, part); -+ if (name.len > 0) try names.append(a, name); -+ } -+ return filterNames(a, input, names.items); -+} -+ -+fn filterNames(a: Allocator, input: []const u8, names: []const []const u8) Allocator.Error![]const u8 { -+ var out: std.ArrayList(u8) = .empty; -+ var done: usize = 0; -+ var stacked: ?usize = null; -+ var text_until: usize = 0; -+ var closes: ?[]const usize = null; -+ var i: usize = 0; -+ while (i < input.len) { -+ const tag = tagAt(input[i..]); -+ if (tag > 0) { -+ try out.appendSlice(a, input[done..i]); -+ done = drop(&out, input, i + tag); -+ i = done; -+ continue; -+ } -+ const end = (if (i < text_until) null else candidateAt(input, i, stacked)) orelse { -+ i += 1; -+ continue; -+ }; -+ stacked = end; -+ const inner = input[i + 1 .. end - 1]; -+ const name = try normalize(a, try untagged(a, inner)); -+ const listed = for (names) |kept| { -+ if (std.mem.eql(u8, name, kept)) break true; -+ } else false; -+ if (listed and !std.mem.eql(u8, name, inner)) { -+ try out.appendSlice(a, input[done..i]); -+ try out.print(a, "[{s}]", .{name}); -+ done = end; -+ i = done; -+ } else if (!listed and isControl(name)) { -+ try out.appendSlice(a, input[done..i]); -+ done = drop(&out, input, end); -+ i = done; -+ } else { -+ const close = (closes orelse made: { -+ closes = try matching(a, input); -+ break :made closes.?; -+ })[i]; -+ // A stray '[' must not shield the rest of the input. -+ text_until = if (close < input.len) close + 1 else end; -+ stacked = text_until; -+ i += 1; -+ } -+ } -+ if (done == 0) return input; -+ try out.appendSlice(a, input[done..]); -+ return out.items; -+} -+ -+fn matching(a: Allocator, input: []const u8) Allocator.Error![]const usize { -+ const closes = try a.alloc(usize, input.len); -+ @memset(closes, input.len); -+ var open: std.ArrayList(usize) = .empty; -+ for (input, 0..) |c, k| switch (c) { -+ '[' => try open.append(a, k), -+ ']' => if (open.pop()) |start| { -+ closes[start] = k; -+ }, -+ else => {}, -+ }; -+ return closes; -+} -+ -+fn normalize(a: Allocator, text: []const u8) Allocator.Error![]const u8 { -+ const lower = try case.lower(a, try unicode.normalize(a, text, .nfc)); -+ var out: std.ArrayList(u8) = .empty; -+ var space = false; -+ var it = std.unicode.Utf8View.initUnchecked(lower).iterator(); -+ while (it.nextCodepointSlice()) |bytes| { -+ if (unicode.isSpace(std.unicode.utf8Decode(bytes) catch unreachable)) { -+ space = out.items.len > 0; -+ continue; -+ } -+ if (space) try out.append(a, ' '); -+ space = false; -+ try out.appendSlice(a, bytes); -+ } -+ var end = out.items.len; -+ while (end > 0) { -+ if (std.mem.indexOfScalar(u8, "!?. ", out.items[end - 1]) != null) { -+ end -= 1; -+ } else if (std.mem.endsWith(u8, out.items[0..end], "\u{2026}")) { -+ end -= "\u{2026}".len; -+ } else break; -+ } -+ return out.items[0..end]; -+} -+ -+fn isControl(name: []const u8) bool { -+ var cue = false; -+ var words = std.mem.tokenizeAny(u8, name, " ,-"); -+ while (words.next()) |word| switch (vocabulary.get(word) orelse return false) { -+ .cues => cue = true, -+ .modifiers => {}, -+ }; -+ return cue; -+} -+ -+fn candidateAt(input: []const u8, i: usize, stacked: ?usize) ?usize { -+ if (input[i] != '[') return null; -+ if (i > 0 and stacked != i) { -+ var start = i - 1; -+ while (start > 0 and input[start] & 0xc0 == 0x80) start -= 1; -+ const before = std.unicode.utf8Decode(input[start..i]) catch unreachable; -+ if (before == ']' or before == '_' or before == '`') return null; -+ if (unicode.bit(unicode.category(before)) & (unicode.letter | unicode.number) != 0) return null; -+ } -+ const close = std.mem.indexOfScalar(u8, input[i..@min(input.len, i + max_control)], ']') orelse return null; -+ const end = i + close + 1; -+ if (end < input.len and input[end] == '(') return null; -+ return end; -+} -+ -+fn tagAt(rest: []const u8) usize { -+ if (rest[0] != '<') return 0; -+ const open: usize = if (rest.len > 1 and rest[1] == '/') 2 else 1; -+ for (scoped_tags) |tag| { -+ const close = open + tag.len; -+ if (rest.len > close and rest[close] == '>' and std.ascii.eqlIgnoreCase(rest[open..close], tag)) return close + 1; -+ } -+ return 0; -+} -+ -+fn untagged(a: Allocator, inner: []const u8) Allocator.Error![]const u8 { -+ if (std.mem.indexOfScalar(u8, inner, '<') == null) return inner; -+ var out: std.ArrayList(u8) = .empty; -+ var i: usize = 0; -+ while (i < inner.len) { -+ const n = tagAt(inner[i..]); -+ if (n == 0) try out.append(a, inner[i]); -+ i += @max(n, 1); -+ } -+ return out.items; -+} -+ -+fn drop(out: *std.ArrayList(u8), input: []const u8, end: usize) usize { -+ var after = end; -+ while (after < input.len and std.ascii.isWhitespace(input[after])) after += 1; -+ var before = out.items.len; -+ while (before > 0 and std.ascii.isWhitespace(out.items[before - 1])) before -= 1; -+ if (before == 0) { -+ out.clearRetainingCapacity(); -+ return after; -+ } -+ const lines_before = std.mem.count(u8, out.items[before..], "\n"); -+ if (std.mem.count(u8, input[end..after], "\n") > lines_before) { -+ out.shrinkRetainingCapacity(before); -+ return end; -+ } -+ return if (before < out.items.len) after else end; -+} -+ -+test "allowed controls accepts normalized open vocabulary and rejects brackets or invalid UTF-8" { -+ for ([_][]const u8{ "unknown", "Sighs", "gentle robot", "low-pitched", "two words", "two\twords", "CAFÉ!", ",sighs,," }) |name| _ = try parse(name); -+ for ([_][]const u8{ "[sighs]", "sighs]", "\xff" }) |bad| try std.testing.expectError(error.InvalidControl, parse(bad)); -+} -+ -+test "allowed controls off returns identical input without allocating" { -+ const input = "[unknown] 你好 [unclosed"; -+ const result = try filter(std.testing.failing_allocator, null, input); -+ try std.testing.expectEqualStrings(input, result); -+ try std.testing.expectEqual(input.ptr, result.ptr); -+ try std.testing.expectError(error.OutOfMemory, filter(std.testing.failing_allocator, try parse("sighs"), "Hi [sighs]")); -+} -+ -+test { -+ _ = @import("drama_allow_golden_test.zig"); -+} -diff --git a/zig/src/server/drama_allow_golden_test.zig b/zig/src/server/drama_allow_golden_test.zig -new file mode 100644 -index 00000000..bdbfcaf3 ---- /dev/null -+++ b/zig/src/server/drama_allow_golden_test.zig -@@ -0,0 +1,43 @@ -+const std = @import("std"); -+const controls = @import("drama_allow.zig"); -+ -+test "delivery controls match synthetic goldens" { -+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ const policy = try controls.parse("sad,whispering"); -+ // Expected strings come from running the filter on synthetic text. -+ const cases = [_][2][]const u8{ -+ .{ "", "" }, -+ .{ "[Pleased!] It works.", "[Pleased!] It works." }, -+ .{ "It [PLEASED] works.", "It [PLEASED] works." }, -+ .{ "It [ softly,\tsighing ] works.", "It works." }, -+ .{ "[Sad] Hi [ WHISPERING! ] there.", "[sad] Hi [whispering] there." }, -+ .{ "[nervous][sighing] I miss you [a] lot.", "I miss you [a] lot." }, -+ .{ "Go on now.", "Go on now." }, -+ .{ "Meet [someone] on [Tuesday].", "Meet [someone] on [Tuesday]." }, -+ .{ "As [see note 3] says.", "As [see note 3] says." }, -+ .{ "Read [sighs](http://example.test).", "Read [sighs](http://example.test)." }, -+ .{ "Use x[i], a[pause] and m[1][sighs].", "Use x[i], a[pause] and m[1][sighs]." }, -+ .{ "Write `[pleased]` before a line.", "Write `[pleased]` before a line." }, -+ .{ "A [softly and unclosed", "A [softly and unclosed" }, -+ .{ "Two spaces [1]\n\nstay.", "Two spaces [1]\n\nstay." }, -+ .{ "[note quietly] stays.", "[note quietly] stays." }, -+ .{ "[Whispering] Hi.", "[whispering] Hi." }, -+ .{ "[see [sighs] now] ok", "[see [sighs] now] ok" }, -+ .{ "[see [note] [sighs] x] stays. [sighs] Go.", "[see [note] [sighs] x] stays. Go." }, -+ .{ "[see [note] [sighs] Go.", "[see [note] Go." }, -+ .{ "Hi [sighs] there, [pleased]\n\nnext. ¶ [sighs] On.", "Hi there, [pleased]\n\nnext. ¶ On." }, -+ .{ "Hi\n[sighs]\nthere word [sighs].", "Hi\nthere word ." }, -+ .{ " \t [sighs] Hi", "Hi" }, -+ .{ "你好[sighs]世界", "你好[sighs]世界" }, -+ .{ "[WHISpering\u{a0}!] café", "[whispering] café" }, -+ .{ "end [sighs]", "end " }, -+ .{ "[unknown] [whispering][sighs][sad]", "[unknown] [whispering][sad]" }, -+ .{ "a]b>c[unfinished", "a]b>c[unfinished" }, -+ .{ "text", "text" }, -+ .{ "[sighs [laughs] tail", "[sighs [laughs] tail" }, -+ .{ "[very very very very very very very very very very very very very very very very very very very very very very very very calm] text", "[very very very very very very very very very very very very very very very very very very very very very very very very calm] text" }, -+ }; -+ for (cases) |c| try std.testing.expectEqualStrings(c[1], try controls.filter(a, policy, c[0])); -+} -diff --git a/zig/src/server/drama_cues.txt b/zig/src/server/drama_cues.txt -new file mode 100644 -index 00000000..cb09be46 ---- /dev/null -+++ b/zig/src/server/drama_cues.txt -@@ -0,0 +1,109 @@ -+# A bracket is a control only when every word is listed and at least one is a cue. -+# Words are split on spaces, commas and hyphens after normalization. -+# Sources: tags in Fish Audio's Drama 3 and Emotion Control docs (docs.fish.audio), the Drama 3 model card and the Drama 3 technical report (arXiv:2610.18010). -+## cues -+happy -+sad -+angry -+excited -+calm -+nervous -+confident -+surprised -+satisfied -+delighted -+scared -+worried -+upset -+frustrated -+depressed -+empathetic -+embarrassed -+disgusted -+moved -+proud -+relaxed -+grateful -+curious -+sarcastic -+disdainful -+unhappy -+anxious -+hysterical -+indifferent -+uncertain -+doubtful -+confused -+disappointed -+regretful -+guilty -+ashamed -+jealous -+envious -+hopeful -+optimistic -+pessimistic -+nostalgic -+lonely -+bored -+contemptuous -+sympathetic -+compassionate -+determined -+resigned -+sighs -+sighing -+laughs -+laugh -+laughing -+laughter -+chuckling -+gasps -+gasping -+pause -+break -+breath -+hesitates -+coughs -+throat -+groaning -+panting -+yawning -+sobbing -+crying -+snoring -+smile -+whispering -+whisper -+shouting -+screaming -+softly -+soft -+quiet -+loudly -+slow -+fast -+emphasis -+forceful -+hurry -+## modifiers -+a -+and -+as -+slightly -+very -+extremely -+with -+in -+tone -+voice -+small -+long -+heavy -+shaky -+clears -+clear -+audience -+background -+crowd -+bright -diff --git a/zig/src/server/serve.zig b/zig/src/server/serve.zig -index 46b76413..2b19d7d4 100644 ---- a/zig/src/server/serve.zig -+++ b/zig/src/server/serve.zig -@@ -147,6 +147,7 @@ pub fn run(gpa: Allocator, io: std.Io, args: cli.Args, s: Setup) u8 { - }; - const config: server_mod.Config = .{ - .browser = .{ .allowed_hosts = args.allowed_host, .allowed_origins = args.allowed_origin }, -+ .allowed_controls = args.allowed_controls, - .served_name = s.served, - .model_dir = s.model_dir, - .model_ids = ids.items, -diff --git a/zig/src/server/server.zig b/zig/src/server/server.zig -index 3633391a..49ebadf9 100644 ---- a/zig/src/server/server.zig -+++ b/zig/src/server/server.zig -@@ -34,6 +34,7 @@ pub const Identity = @import("identity.zig").Identity; - - pub const Config = struct { - browser: request_guard.Policy = .{}, -+ allowed_controls: ?@import("drama_allow.zig").Policy = null, - sse_keepalive_s: f64 = 15, - served_name: []const u8, - model_dir: []const u8 = "", -diff --git a/zig/src/server/speech.zig b/zig/src/server/speech.zig -index ec6166be..b492e150 100644 ---- a/zig/src/server/speech.zig -+++ b/zig/src/server/speech.zig -@@ -95,7 +95,7 @@ fn prepare(srv: *Server, cx: *Cx, sp: api.SpeechInfo, raw: Value) errors.Refused - const model = raw.strField("model") orelse return cx.fail(.request, "model is required: send {s}", .{srv.config.served_name}); - if (!listed(srv.config.model_ids, model)) - return cx.fail(.request, "model {s} is not served here: send {s}", .{ try pyrepr.repr(cx.a, .{ .string = model }), srv.config.served_name }); -- const input = raw.strField("input") orelse return cx.refuse("input must be a non-empty string"); -+ const input = try @import("drama_allow.zig").filter(cx.a, srv.config.allowed_controls, raw.strField("input") orelse return cx.refuse("input must be a non-empty string")); - if (!std.unicode.utf8ValidateSlice(input)) return cx.refuse("input must be valid UTF-8 text"); - if (reply_text.pyStrip(input).len == 0) return cx.refuse("input must be a non-empty string"); - if ((std.unicode.utf8CountCodepoints(input) catch unreachable) > max_input_chars) -@@ -428,3 +428,24 @@ test "response_format is WAV or PCM, stripped and case-folded as Python does" { - try std.testing.expectEqual(Format.pcm, try responseFormat(&cx, "\u{3000}Pcm\x1c")); - for ([_][]const u8{ "mp3", "", "wave", "pcm16" }) |bad| try std.testing.expectError(error.Refused, responseFormat(&cx, bad)); - } -+ -+test "allowed controls filters before speech input validation" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ var srv: Server = undefined; -+ srv.config = .{ .served_name = "m", .model_ids = &.{"m"}, .allowed_controls = try @import("drama_allow.zig").parse("") }; -+ const sp: api.SpeechInfo = .{ .sample_rate = 44100, .frame_length = 2048, .max_frames = 1024, .max_prompt_tokens = 4096 }; -+ const long = try a.alloc(u8, max_input_chars + 1); -+ @memset(long, 'x'); -+ const body = (try json.parse(a, try std.fmt.allocPrint(a, "{{\"model\":\"m\",\"input\":\"[{s}]\"}}", .{long}))).ok; -+ var cx: Cx = .{ .a = a }; -+ try std.testing.expectError(error.Refused, prepare(&srv, &cx, sp, body)); -+ try std.testing.expectEqualStrings("input must be at most 4096 characters", cx.message); -+ const controls = (try json.parse(a, "{\"model\":\"m\",\"input\":\"[sighs]\"}")).ok; -+ try std.testing.expectError(error.Refused, prepare(&srv, &cx, sp, controls)); -+ try std.testing.expectEqualStrings("input must be a non-empty string", cx.message); -+ srv.config.allowed_controls = null; -+ try std.testing.expectError(error.Refused, prepare(&srv, &cx, sp, body)); -+ try std.testing.expectEqualStrings("input must be at most 4096 characters", cx.message); -+} diff --git a/engine/patches/0005-feat-server-serve-attested-Drama-voice-catalogs.patch b/engine/patches/0005-feat-server-serve-attested-Drama-voice-catalogs.patch deleted file mode 100644 index af8915b..0000000 --- a/engine/patches/0005-feat-server-serve-attested-Drama-voice-catalogs.patch +++ /dev/null @@ -1,634 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: plotarmordev <299844489+plotarmordev@users.noreply.github.com> -Date: Tue, 20 Oct 2026 17:28:40 +0800 -Subject: [PATCH 05/11] feat(server): serve attested Drama voice catalogs - -K9: net diff of feat/http-voices 73249c5a (4 commits), upstream #1135. ---- - CHANGELOG.md | 1 + - docs/drama-http-voices.md | 23 +++ - zig/build/server.zig | 19 +++ - zig/src/families/drama3/prompt.zig | 4 +- - zig/src/server/cli.zig | 14 ++ - zig/src/server/cli_test.zig | 14 ++ - zig/src/server/drama_voice.zig | 208 ++++++++++++++++++++++++++ - zig/src/server/routes.zig | 5 + - zig/src/server/serve.zig | 6 +- - zig/src/server/server.zig | 8 + - zig/src/server/speech.zig | 34 ++++- - zig/src/server/speech_stream_test.zig | 68 ++++++++- - 12 files changed, 389 insertions(+), 15 deletions(-) - create mode 100644 docs/drama-http-voices.md - create mode 100644 zig/src/server/drama_voice.zig - -diff --git a/CHANGELOG.md b/CHANGELOG.md -index b7372176..18e78aca 100644 ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -7,6 +7,7 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - - Drama 3 on CUDA prefills bounded row chunks in each shared round. `TF_DRAMA_PREFILL_ROWS` selects 1 through 1024 rows (default 128); the generation tool uses `--max-rows`. Batched projections and attention can change rounding relative to single-row prefill; oracle checks now compare the selected prompt chunks at their reference-row offsets. - - Drama 3 serving accepts `--max-frames` to bound synthesis frames, preserving the stock cap when omitted. - - `serve --allowed-controls LIST` opts into stripping unlisted bracketed speech controls before input validation and tokenization. Omission preserves the input unchanged; see [speech controls](docs/speech-controls.md) for the open-vocabulary name grammar and malformed-input policy. -+- Drama speech serving accepts opt-in attested voice catalogs with `--voices`, `--default-voice` and `GET /v1/voices`, using the family's reference prompt builder. Catalog loading honors the parser's inclusive file-size bound and reports startup refusal reasons. - - ## 1.1.1 - -diff --git a/docs/drama-http-voices.md b/docs/drama-http-voices.md -new file mode 100644 -index 00000000..785479df ---- /dev/null -+++ b/docs/drama-http-voices.md -@@ -0,0 +1,23 @@ -+# Attested HTTP voices -+ -+`tensorfold-native serve MODEL --voices DIR --default-voice NAME` loads the catalog at startup. Without these flags the built-in voice and speech request lowering are unchanged. `--default-voice` defaults to `default`; a configured name must exist. A missing, malformed or unattested catalog file refuses startup rather than silently substituting a voice. Restart to reload files. -+ -+`POST /v1/audio/speech` selects a catalog entry with `voice` (or the existing `speaker` alias). An absent, empty or case-insensitive `default` selects the configured default. Other names match exactly. Request-supplied reference arrays and paths remain unsupported. `GET /v1/voices` returns `{"voices": ["default", ...]}` with catalog names sorted, without transcripts, codes or attestation notes. Non-speech models return the existing unknown-route response. -+ -+Each regular `NAME.json` file contains: -+ -+```json -+{ -+ "format": "drama3-http-voice/1", -+ "source": {"kind": "synthetic", "note": "Generated integer codes for a parser example, not recorded speech."}, -+ "references": [{"text": "A small cloud passes.", "codes": [[7,1,2,3,4,5,6,7,8,9]]}] -+} -+``` -+ -+The example codes are synthetic, not an acoustic-quality fixture. Names contain ASCII letters, digits, hyphens and underscores, begin with a letter or digit, and cannot be `default` in any case. Names are bounded by the loader. Symlink catalog entries are refused. -+ -+The mandatory `source.kind` is `synthetic` (generated audio), `self` (the operator's own voice), or `consented` (explicit permission from the speaker). `source.note` must be nonempty UTF-8 describing the provenance or consent. This is an operator attestation, not proof of identity or consent; do not use someone else's voice without permission. Notes are not returned by the API. -+ -+Reference `text` is the exact transcript. Codes are frame-major signed integers, with the semantic code first and residual codebooks following, in the same order as upstream `drama3-generate --encode` emits. Encode a synthetic reference through upstream's codec using `--codec`, `--ref-audio` and `--encode`; decode its native signed integer binary output into frame rows when preparing the JSON. This PR does not provide voice-file writer tooling or an encoder implementation. Do not substitute codes from an unrelated codec. -+ -+The loader enforces the family's reference count, transcript length, frame count and codebook ranges before serving. It also rejects empty references, structural prompt tokens, unsupported speaker tags, oversized files and oversized catalogs. The assembled prompt must still fit the engine's advertised prompt budget. Reference rows go through the same family `prompt.build` used by the generation CLI; only the tokenizer interface is adapted. -diff --git a/zig/build/server.zig b/zig/build/server.zig -index 48ad8ace..e32bc9f7 100644 ---- a/zig/build/server.zig -+++ b/zig/build/server.zig -@@ -20,6 +20,25 @@ pub fn addTo( - if (!m.import_table.contains("living")) m.addImport("living", b.createModule(.{ .root_source_file = b.path("zig/src/living/living.zig"), .target = target, .optimize = optimize, .link_libc = true })); - const j = json(b, target, optimize); - m.addImport("json", j); -+ const family = if (m.import_table.get("native_engines")) |engines| engines.import_table.get("drama3") else null; -+ if (family) |drama3| { -+ const source = b.addWriteFiles().add("drama_prompt.zig", -+ \\pub const Reference = @import("drama3").prompt.Reference; -+ \\pub const build = @import("drama3").prompt.build; -+ ); -+ m.addImport("drama_prompt", b.createModule(.{ -+ .root_source_file = source, -+ .target = target, -+ .optimize = optimize, -+ .imports = &.{.{ .name = "drama3", .module = drama3 }}, -+ })); -+ } else { -+ m.addImport("drama_prompt", b.createModule(.{ -+ .root_source_file = b.path("zig/src/families/drama3/prompt.zig"), -+ .target = target, -+ .optimize = optimize, -+ })); -+ } - if (m.import_table.get("engine_api")) |api| if (api.import_table.get("lanes")) |lanes| m.addImport("lanes", lanes); - // a build whose engines learn hands the server their own living module; the rest read only its families - if (m.import_table.get("living") == null) m.addImport("living", b.createModule(.{ .root_source_file = b.path("zig/src/living/living.zig"), .target = target, .optimize = optimize, .link_libc = true })); -diff --git a/zig/src/families/drama3/prompt.zig b/zig/src/families/drama3/prompt.zig -index f5ada1ee..6b3a8749 100644 ---- a/zig/src/families/drama3/prompt.zig -+++ b/zig/src/families/drama3/prompt.zig -@@ -1,9 +1,7 @@ - //! Drama 3 prompt rows in the reference-bracket-v1 layout of SGLang-Omni's prompt.py, each part tokenized on its own. - const std = @import("std"); --const core = @import("core"); - const cfg = @import("config.zig"); - --const Tokenizer = core.tokenizer.Tokenizer; - const W = cfg.frame_width; - - pub const tts_instruction = "convert the provided text to speech"; -@@ -60,7 +58,7 @@ fn refTranscript(a: std.mem.Allocator, transcript: []const u8) ![]u8 { - return std.mem.concat(a, u8, &.{ body[0..tl], tagged[0..lead], body[tl..] }); - } - --pub fn build(gpa: std.mem.Allocator, tok: *const Tokenizer, text: []const u8, refs: []const Reference) ![][W]i32 { -+pub fn build(gpa: std.mem.Allocator, tok: anytype, text: []const u8, refs: []const Reference) ![][W]i32 { - if (text.len == 0 or text.len > 16384 or !std.unicode.utf8ValidateSlice(text) or refs.len > 4) return error.BadPrompt; - for (refs) |r| { - if (r.transcript.len == 0 or r.transcript.len > 4096 or !std.unicode.utf8ValidateSlice(r.transcript) or -diff --git a/zig/src/server/cli.zig b/zig/src/server/cli.zig -index 3ca82675..45b57fd6 100644 ---- a/zig/src/server/cli.zig -+++ b/zig/src/server/cli.zig -@@ -47,6 +47,8 @@ pub const flags = [_]Flag{ - .{ .name = "--port", .native = true }, - .{ .name = "--name", .native = true }, - .{ .name = "--chat-template", .native = true }, -+ .{ .name = "--voices", .native = true }, -+ .{ .name = "--default-voice", .native = true }, - .{ .name = "--alias", .kind = .append, .native = true }, - .{ .name = "--api-key", .kind = .append, .native = true }, - .{ .name = "--api-key-file", .native = true }, -@@ -141,6 +143,8 @@ pub const Args = struct { - port: u16 = 8080, - name: []const u8 = "", - chat_template: ?[]const u8 = null, -+ voices: ?[]const u8 = null, -+ default_voice: []const u8 = "default", - alias: []const []const u8 = &.{}, - api_key: []const []const u8 = &.{}, - allowed_host: []const []const u8 = &.{}, -@@ -305,6 +309,16 @@ fn apply(a: Allocator, out: *Args, name: []const u8, value: ?[]const u8, u: *Usa - } - if (try cudaFlag(a, out, name, v, u) or try gpuFlag(a, out, name, v, u)) return; - if (try recallFlag(a, out, name, v, u)) return; -+ if (is(name, "--voices")) { -+ out.voices = v; -+ return; -+ } -+ if (is(name, "--default-voice")) { -+ if (!std.mem.eql(u8, v, "default") and !@import("drama_voice.zig").validName(v)) -+ return fail(u, a, "invalid --default-voice: {s}", .{v}); -+ out.default_voice = v; -+ return; -+ } - if (is(name, "--chat-template")) { - out.chat_template = v; - return; -diff --git a/zig/src/server/cli_test.zig b/zig/src/server/cli_test.zig -index 00204ba7..b7da0ff5 100644 ---- a/zig/src/server/cli_test.zig -+++ b/zig/src/server/cli_test.zig -@@ -46,6 +46,20 @@ test "allowed controls CLI is opt-in and validates name syntax only" { - try std.testing.expect(std.mem.indexOf(u8, out.written(), "\"--allowed-controls\": {}") != null); - } - -+test "voices: CLI defaults and explicit catalog" { -+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ var u: Usage = .{}; -+ const defaults = try parse(a, &.{"m"}, &u); -+ try std.testing.expect(defaults.voices == null); -+ try std.testing.expectEqualStrings("default", defaults.default_voice); -+ const named = try parse(a, &.{ "m", "--voices", "catalog", "--default-voice=tone" }, &u); -+ try std.testing.expectEqualStrings("catalog", named.voices.?); -+ try std.testing.expectEqualStrings("tone", named.default_voice); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--default-voice=../tone" }, &u)); -+} -+ - test "parse and capabilities share the table" { - var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer arena.deinit(); -diff --git a/zig/src/server/drama_voice.zig b/zig/src/server/drama_voice.zig -new file mode 100644 -index 00000000..576cc43d ---- /dev/null -+++ b/zig/src/server/drama_voice.zig -@@ -0,0 +1,208 @@ -+//! Startup-owned voice references; only catalog names, never request paths, select files. -+const std = @import("std"); -+const json = @import("json"); -+const prompt = @import("drama_prompt"); -+const text_mod = @import("model_text.zig"); -+const checks = @import("drama_prompt.zig"); -+const Allocator = std.mem.Allocator; -+ -+pub const Voice = struct { name: []const u8, references: []const prompt.Reference }; -+pub const max_frames = (30 * 44100 + 2047) / 2048; -+pub const max_file_bytes = 256 * 1024; -+ -+pub fn validName(name: []const u8) bool { -+ if (name.len == 0 or name.len > 64 or !std.ascii.isAlphanumeric(name[0]) or std.ascii.eqlIgnoreCase(name, "default")) return false; -+ for (name) |c| if (!std.ascii.isAlphanumeric(c) and c != '-' and c != '_') return false; -+ return true; -+} -+ -+fn text(value: ?[]const u8, limit: usize) ![]const u8 { -+ const s = value orelse return error.BadVoice; -+ if (s.len > limit or std.mem.trim(u8, s, " \t\r\n").len == 0 or !std.unicode.utf8ValidateSlice(s)) return error.BadVoice; -+ return s; -+} -+ -+pub fn parse(a: Allocator, bytes: []const u8) ![]const prompt.Reference { -+ if (bytes.len > max_file_bytes) return error.BadVoice; -+ const doc = switch (try json.parse(a, bytes)) { -+ .ok => |v| v, -+ else => return error.BadVoice, -+ }; -+ if (!std.mem.eql(u8, doc.strField("format") orelse "", "drama3-http-voice/1")) return error.BadVoice; -+ const source = doc.get("source") orelse return error.MissingAttestation; -+ const kind = source.strField("kind") orelse return error.MissingAttestation; -+ if (!std.mem.eql(u8, kind, "synthetic") and !std.mem.eql(u8, kind, "self") and !std.mem.eql(u8, kind, "consented")) return error.MissingAttestation; -+ _ = text(source.strField("note"), 4096) catch return error.MissingAttestation; -+ const values = (doc.get("references") orelse return error.BadVoice).items() orelse return error.BadVoice; -+ if (values.len == 0 or values.len > 4) return error.BadVoice; -+ const refs = try a.alloc(prompt.Reference, values.len); -+ for (values, refs) |value, *ref| { -+ const transcript = try text(value.strField("text"), 4096); -+ if (try checks.problem(a, transcript) != null) return error.BadVoice; -+ const rows = (value.get("codes") orelse return error.BadVoice).items() orelse return error.BadVoice; -+ if (rows.len == 0 or rows.len > max_frames) return error.BadVoice; -+ const codes = try a.alloc([10]i32, rows.len); -+ for (rows, codes) |row, *code| { -+ const cells = row.items() orelse return error.BadVoice; -+ if (cells.len != 10) return error.BadVoice; -+ for (cells, 0..) |cell, i| { -+ const n = cell.int64() orelse return error.BadVoice; -+ if (n < 0 or n >= (if (i == 0) @as(i64, 4096) else 1024)) return error.BadVoice; -+ code[i] = @intCast(n); -+ } -+ } -+ ref.* = .{ .transcript = transcript, .codes = codes }; -+ } -+ return refs; -+} -+ -+pub fn load(a: Allocator, io: std.Io, path: []const u8) ![]const Voice { -+ var dir = try std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true }); -+ defer dir.close(io); -+ var iter = dir.iterate(); -+ var voices: std.ArrayList(Voice) = .empty; -+ while (try iter.next(io)) |entry| { -+ if (!std.mem.endsWith(u8, entry.name, ".json")) continue; -+ const name = entry.name[0 .. entry.name.len - 5]; -+ if (entry.kind != .file or !validName(name)) return error.BadVoiceName; -+ if (voices.items.len >= 256) return error.TooManyVoices; -+ const bytes = try dir.readFileAlloc(io, entry.name, a, .limited(max_file_bytes + 1)); -+ try voices.append(a, .{ .name = try a.dupe(u8, name), .references = try parse(a, bytes) }); -+ } -+ std.mem.sort(Voice, voices.items, {}, struct { -+ fn less(_: void, left: Voice, right: Voice) bool { -+ return std.mem.lessThan(u8, left.name, right.name); -+ } -+ }.less); -+ return voices.items; -+} -+ -+pub fn select(voices: []const Voice, default: []const u8, requested: []const u8) ![]const prompt.Reference { -+ const name = if (requested.len == 0 or std.ascii.eqlIgnoreCase(requested, "default")) default else requested; -+ if (std.mem.eql(u8, name, "default")) return &.{}; -+ for (voices) |voice| if (std.mem.eql(u8, voice.name, name)) return voice.references; -+ return error.UnknownVoice; -+} -+ -+const Tokenizer = struct { -+ value: text_mod.Text, -+ pub fn specialTokenId(t: Tokenizer, s: []const u8) ?u32 { -+ return t.value.tokenId(s); -+ } -+ pub fn encode(t: Tokenizer, a: Allocator, s: []const u8) text_mod.Error![]u32 { -+ return t.value.encode(a, s, false); -+ } -+}; -+ -+pub fn build(a: Allocator, tokenizer: text_mod.Text, input: []const u8, refs: []const prompt.Reference) ![][11]i32 { -+ return prompt.build(a, Tokenizer{ .value = tokenizer }, input, refs); -+} -+ -+pub fn listing(a: Allocator, voices: []const Voice) !json.Value { -+ const data = try a.alloc(json.Value, voices.len + 1); -+ data[0] = .{ .string = "default" }; -+ for (voices, data[1..]) |voice, *slot| slot.* = .{ .string = voice.name }; -+ const object = try json.newObject(a); -+ try object.put(a, "voices", .{ .array = data }); -+ return .{ .object = object }; -+} -+ -+test "voices: synthetic parsing, attestation, bounds and selection" { -+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ const fixture = -+ \\{"format":"drama3-http-voice/1","source":{"kind":"synthetic","note":"Generated integer codes."}, -+ \\"references":[{"text":"A small cloud passes.","codes":[[4095,1,2,3,4,5,6,7,8,1023]]}]} -+ ; -+ const refs = try parse(a, fixture); -+ var tmp = std.testing.tmpDir(.{}); -+ defer tmp.cleanup(); -+ const path = try std.fmt.allocPrint(a, ".zig-cache/tmp/{s}", .{tmp.sub_path}); -+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "tone.json", .data = fixture }); -+ const loaded = try load(a, std.testing.io, path); -+ try std.testing.expectEqualStrings("tone", loaded[0].name); -+ try std.testing.expectEqualSlices([10]i32, refs[0].codes, loaded[0].references[0].codes); -+ const padded = try a.alloc(u8, max_file_bytes + 1); -+ @memcpy(padded[0..fixture.len], fixture); -+ @memset(padded[fixture.len..], ' '); -+ for ([_]usize{ max_file_bytes - 1, max_file_bytes }) |size| { -+ _ = try parse(a, padded[0..size]); -+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "tone.json", .data = padded[0..size] }); -+ const boundary = try load(a, std.testing.io, path); -+ try std.testing.expectEqualSlices([10]i32, refs[0].codes, boundary[0].references[0].codes); -+ } -+ try std.testing.expectError(error.BadVoice, parse(a, padded)); -+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "tone.json", .data = padded }); -+ try std.testing.expectError(error.StreamTooLong, load(a, std.testing.io, path)); -+ try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "tone.json", .data = "{\"format\":\"drama3-http-voice/1\"}" }); -+ try std.testing.expectError(error.MissingAttestation, load(a, std.testing.io, path)); -+ try std.testing.expectEqual(@as(i32, 4095), refs[0].codes[0][0]); -+ const voices = [_]Voice{.{ .name = "tone", .references = refs }}; -+ try std.testing.expectEqual(refs.ptr, (try select(&voices, "tone", "default")).ptr); -+ try std.testing.expectEqual(refs.ptr, (try select(&voices, "default", "tone")).ptr); -+ try std.testing.expectEqual(@as(usize, 0), (try select(&.{}, "default", "")).len); -+ try std.testing.expectError(error.UnknownVoice, select(&voices, "default", "../tone")); -+ try std.testing.expectEqualStrings("{\"voices\": [\"default\", \"tone\"]}", try json.stringify(a, try listing(a, &voices), .{})); -+ const doc = (try json.parse(a, fixture)).ok; -+ _ = doc.object.swapRemove("source"); -+ try std.testing.expectError(error.MissingAttestation, parse(a, try json.stringify(a, doc, .{}))); -+ for ([_][]const u8{ "", " ", "null", "{\"kind\":\"synthetic\"}", "{\"kind\":\"other\",\"note\":\"n\"}" }) |bad| { -+ const bytes = try std.fmt.allocPrint(a, "{{\"format\":\"drama3-http-voice/1\",\"source\":{s}}}", .{bad}); -+ try std.testing.expectError(if (bad.len == 0 or std.mem.eql(u8, bad, " ")) error.BadVoice else error.MissingAttestation, parse(a, bytes)); -+ } -+ for ([_][]const u8{ "[]", "[[0]]", "[[-1,0,0,0,0,0,0,0,0,0]]", "[[4096,0,0,0,0,0,0,0,0,0]]", "[[0,1024,0,0,0,0,0,0,0,0]]", "[[0.0,0,0,0,0,0,0,0,0,0]]" }) |codes| { -+ const bytes = try std.fmt.allocPrint(a, "{{\"format\":\"drama3-http-voice/1\",\"source\":{{\"kind\":\"synthetic\",\"note\":\"Generated.\"}},\"references\":[{{\"text\":\"A cloud.\",\"codes\":{s}}}]}}", .{codes}); -+ try std.testing.expectError(error.BadVoice, parse(a, bytes)); -+ } -+ const oversized = try a.alloc(u8, max_file_bytes + 1); -+ try std.testing.expectError(error.BadVoice, parse(a, oversized)); -+ for ([_][]const u8{ "default", "DEFAULT", "../tone", "a/b", "" }) |name| try std.testing.expect(!validName(name)); -+ const bounded = (try json.parse(a, fixture)).ok; -+ const reference = bounded.get("references").?.array[0]; -+ const code = reference.get("codes").?.array[0]; -+ const many = try a.alloc(json.Value, max_frames + 1); -+ @memset(many, code); -+ try reference.object.put(a, "codes", .{ .array = many }); -+ try std.testing.expectError(error.BadVoice, parse(a, try json.stringify(a, bounded, .{}))); -+ try reference.object.put(a, "codes", .{ .array = many[0..max_frames] }); -+ _ = try parse(a, try json.stringify(a, bounded, .{})); -+ try reference.object.put(a, "codes", .{ .array = many[0..1] }); -+ const long = try a.alloc(u8, 4097); -+ @memset(long, 'a'); -+ try reference.object.put(a, "text", .{ .string = long }); -+ try std.testing.expectError(error.BadVoice, parse(a, try json.stringify(a, bounded, .{}))); -+ try reference.object.put(a, "text", .{ .string = long[0..4096] }); -+ _ = try parse(a, try json.stringify(a, bounded, .{})); -+ try reference.object.put(a, "text", .{ .string = "A cloud." }); -+ const repeated = try a.alloc(json.Value, 5); -+ @memset(repeated, reference); -+ try bounded.object.put(a, "references", .{ .array = repeated }); -+ try std.testing.expectError(error.BadVoice, parse(a, try json.stringify(a, bounded, .{}))); -+ try bounded.object.put(a, "references", .{ .array = repeated[0..4] }); -+ try std.testing.expectEqual(@as(usize, 4), (try parse(a, try json.stringify(a, bounded, .{}))).len); -+} -+ -+test "voices: real tokenizer rows equal the CLI path without server postprocessing" { -+ const gpa = std.testing.allocator; -+ var arena = std.heap.ArenaAllocator.init(gpa); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ var tok = try @import("tokenizer").parse(gpa, -+ \\{"model":{"type":"WordLevel","vocab":{"[UNK]":0,"<|semantic:0|>":1000},"unk_token":"[UNK]"}} -+ ); -+ defer tok.deinit(); -+ var hf: @import("hf_text.zig").HfText = .{ -+ .gpa = gpa, -+ .arena = std.heap.ArenaAllocator.init(gpa), -+ .tok = tok, -+ .prefix = &.{17}, -+ .suffix = &.{18}, -+ }; -+ defer hf.arena.deinit(); -+ const codes = [_][10]i32{.{ 7, 1, 2, 3, 4, 5, 6, 7, 8, 9 }}; -+ const refs = [_]prompt.Reference{.{ .transcript = "A small cloud passes.", .codes = &codes }}; -+ const cli_rows = try prompt.build(a, &tok, "A cloud.", &refs); -+ const http_rows = try build(a, hf.text(), "A cloud.", &refs); -+ try std.testing.expectEqualSlices(u8, std.mem.sliceAsBytes(cli_rows), std.mem.sliceAsBytes(http_rows)); -+} -diff --git a/zig/src/server/routes.zig b/zig/src/server/routes.zig -index 6fa2b0dc..1e472537 100644 ---- a/zig/src/server/routes.zig -+++ b/zig/src/server/routes.zig -@@ -55,6 +55,11 @@ pub fn unknown(conn: *Conn, a: Allocator) void { - - fn get(srv: *Server, conn: *Conn, a: Allocator) void { - const route = auth.routePath(conn.path); -+ if (std.mem.eql(u8, route, "/v1/voices")) { -+ if (srv.info.speech == null) return unknown(conn, a); -+ const value = @import("drama_voice.zig").listing(a, srv.voices) catch return; -+ return sendValue(conn, a, 200, value); -+ } - if (std.mem.eql(u8, route, "/memory") or std.mem.eql(u8, route, "/v1/memory")) - return memory.get(srv, conn, a) catch {}; - if (responses.route(route)) |rid| if (rid.len > 0) return responses.get(srv, conn, a, rid); -diff --git a/zig/src/server/serve.zig b/zig/src/server/serve.zig -index 2b19d7d4..50ec90fc 100644 ---- a/zig/src/server/serve.zig -+++ b/zig/src/server/serve.zig -@@ -150,6 +150,8 @@ pub fn run(gpa: Allocator, io: std.Io, args: cli.Args, s: Setup) u8 { - .allowed_controls = args.allowed_controls, - .served_name = s.served, - .model_dir = s.model_dir, -+ .voices_dir = args.voices, -+ .default_voice = args.default_voice, - .model_ids = ids.items, - .default_max_tokens = args.max_tokens, - .enable_thinking = args.thinking, -@@ -176,8 +178,8 @@ pub fn run(gpa: Allocator, io: std.Io, args: cli.Args, s: Setup) u8 { - .identity = s.identity, - .environ = s.environ, - }; -- const srv = server_mod.Server.init(gpa, io, s.engine, s.text, config, if (store.enabled()) &store else null) catch { -- std.debug.print("tensorfold: the server could not start\n", .{}); -+ const srv = server_mod.Server.init(gpa, io, s.engine, s.text, config, if (store.enabled()) &store else null) catch |e| { -+ std.debug.print("tensorfold: the server could not start: {s}\n", .{@errorName(e)}); - return 1; - }; - defer srv.deinit(); -diff --git a/zig/src/server/server.zig b/zig/src/server/server.zig -index 49ebadf9..e7b00a8a 100644 ---- a/zig/src/server/server.zig -+++ b/zig/src/server/server.zig -@@ -38,6 +38,8 @@ pub const Config = struct { - sse_keepalive_s: f64 = 15, - served_name: []const u8, - model_dir: []const u8 = "", -+ voices_dir: ?[]const u8 = null, -+ default_voice: []const u8 = "default", - /// The served name first, then aliases, without repeats. - model_ids: []const []const u8, - default_max_tokens: i64 = 4096, -@@ -88,6 +90,7 @@ pub const Server = struct { - info: api.Info, - text: model_text.Text, - config: Config, -+ voices: []const @import("drama_voice.zig").Voice = &.{}, - keys: ?*auth.Store, - late_system: []const u8 = "system", - needs_user_after_tool: bool = false, -@@ -129,6 +132,11 @@ pub const Server = struct { - const srv = try gpa.create(Server); - srv.* = .{ .gpa = gpa, .io = io, .engine = engine, .info = engine.info(), .text = text, .config = config, .keys = keys, .metrics = .{ .gpa = gpa }, .store = .{ .gpa = gpa }, .arena = .init(gpa), .slide = .init(gpa, io, config.slide_graph), .teacher = .init(gpa) }; - errdefer srv.deinit(); // a later step's failure stops the keepalive before the caller closes the engine -+ if (config.voices_dir) |dir| { -+ if (srv.info.speech == null) return error.VoicesRequireSpeech; -+ srv.voices = try @import("drama_voice.zig").load(srv.arena.allocator(), io, dir); -+ } -+ _ = try @import("drama_voice.zig").select(srv.voices, config.default_voice, "default"); - if (!srv.info.text_generation) { - if (config.cortheon) return error.CortheonRequiresTextGeneration; - return srv; -diff --git a/zig/src/server/speech.zig b/zig/src/server/speech.zig -index b492e150..c27b0e07 100644 ---- a/zig/src/server/speech.zig -+++ b/zig/src/server/speech.zig -@@ -40,7 +40,7 @@ pub const Format = enum { - } - }; - --const Plan = struct { prompt: []const u32, seed: u64, format: Format, stream: bool }; -+const Plan = struct { prompt: []const u32, frames: ?[]const [11]i32 = null, seed: u64, format: Format, stream: bool }; - - pub fn post(srv: *Server, c: *Conn, a: Allocator) !void { - const sp = srv.info.speech orelse { -@@ -104,9 +104,24 @@ fn prepare(srv: *Server, cx: *Cx, sp: api.SpeechInfo, raw: Value) errors.Refused - const stream = if (raw.get("stream")) |s| s == .bool and s.bool else false; - if (stream and format != .pcm) return cx.refuse("stream=true requires response_format='pcm'"); - const voice = raw.strField("voice") orelse raw.strField("speaker") orelse "default"; -- if (voice.len > 0 and !std.ascii.eqlIgnoreCase(voice, "default")) -- return cx.fail(.request, drama_prompt.error_prefix ++ " unknown voice {s}; only the default voice is supported", .{try pyrepr.repr(cx.a, .{ .string = voice })}); -+ const refs = @import("drama_voice.zig").select(srv.voices, srv.config.default_voice, voice) catch { -+ const name = try pyrepr.repr(cx.a, .{ .string = voice }); -+ if (srv.config.voices_dir != null) -+ return cx.fail(.request, drama_prompt.error_prefix ++ " unknown voice {s}; see /v1/voices", .{name}); -+ return cx.fail(.request, drama_prompt.error_prefix ++ " unknown voice {s}; only the default voice is supported", .{name}); -+ }; - if (try drama_prompt.problem(cx.a, input)) |message| return cx.refuse(message); -+ if (refs.len > 0) { -+ const frames = @import("drama_voice.zig").build(cx.a, srv.text, input, refs) catch |e| return switch (e) { -+ error.OutOfMemory => error.OutOfMemory, -+ else => cx.other("the tokenizer cannot encode this voice prompt"), -+ }; -+ if (frames.len > sp.max_prompt_tokens) -+ return cx.fail(.request, drama_prompt.error_prefix ++ " a prompt of {d} rows exceeds the maximum allowed length of {d} rows", .{ frames.len, sp.max_prompt_tokens }); -+ const prompt = try cx.a.alloc(u32, frames.len); -+ for (frames, prompt) |row, *token| token.* = @intCast(row[0]); -+ return .{ .prompt = prompt, .frames = frames, .seed = api.seedFor(prompt, srv.config.seed_salt), .format = format, .stream = stream }; -+ } - const prompt = drama_prompt.encode(cx.a, srv.text, input) catch |e| return switch (e) { - error.OutOfMemory => error.OutOfMemory, - error.Template => cx.other("the tokenizer cannot encode this prompt"), -@@ -130,11 +145,14 @@ fn synthesize(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInf - const pcm = srv.gpa.alloc(f32, pcm_len) catch return fail(c, cx, id, .server, "out of memory"); - defer srv.gpa.free(pcm); - var result: api.SpeechResult = .{}; -- const frames = cx.a.alloc([11]i32, plan.prompt.len) catch return fail(c, cx, id, .server, "out of memory"); -- for (frames, plan.prompt) |*row, token| { -- row.* = @splat(0); -- row[0] = std.math.cast(i32, token) orelse return fail(c, cx, id, .request, "invalid speech token"); -- } -+ const frames = plan.frames orelse blk: { -+ const rows = cx.a.alloc([11]i32, plan.prompt.len) catch return fail(c, cx, id, .server, "out of memory"); -+ for (rows, plan.prompt) |*row, token| { -+ row.* = @splat(0); -+ row[0] = std.math.cast(i32, token) orelse return fail(c, cx, id, .request, "invalid speech token"); -+ } -+ break :blk rows; -+ }; - const request: api.SpeechRequest = .{ - .prompt = plan.prompt, - .frames = frames, -diff --git a/zig/src/server/speech_stream_test.zig b/zig/src/server/speech_stream_test.zig -index 7c1582bc..ddd94a07 100644 ---- a/zig/src/server/speech_stream_test.zig -+++ b/zig/src/server/speech_stream_test.zig -@@ -22,6 +22,10 @@ const Script = struct { - release: std.atomic.Value(bool) = .init(false), - cancelled: std.atomic.Value(bool) = .init(false), - thread: ?std.Thread = null, -+ voices: []const @import("drama_voice.zig").Voice = &.{}, -+ get_voices: bool = false, -+ expected_rows: ?[]const [11]i32 = null, -+ rows_equal: bool = false, - - fn engine(e: *Script) api.Engine { - return .{ .ctx = e, .vtable = &.{ .info = info, .submit = submit, .cancel = cancel, .status = status, .memory = memory, .submit_speech = speech } }; -@@ -32,6 +36,7 @@ const Script = struct { - } - fn speech(ctx: *anyopaque, id: api.Id, request: *const api.SpeechRequest, sink: api.Sink) api.SpeechSubmitError!void { - const e: *Script = @ptrCast(@alignCast(ctx)); -+ if (e.expected_rows) |expected| e.rows_equal = std.mem.eql(u8, std.mem.sliceAsBytes(expected), std.mem.sliceAsBytes(request.frames)); - if (e.inline_) return run(e, id, request, sink); - e.thread = std.Thread.spawn(.{}, run, .{ e, id, request, sink }) catch return error.Busy; - } -@@ -80,7 +85,8 @@ const Bytes = struct { - for (ids, out) |id, *byte| byte.* = @intCast(id); - return out; - } -- fn tokenId(_: *anyopaque, _: []const u8) ?u32 { -+ fn tokenId(_: *anyopaque, piece: []const u8) ?u32 { -+ if (std.mem.eql(u8, piece, "<|semantic:0|>")) return 1000; - return null; - } - fn tokenString(_: *anyopaque, a: std.mem.Allocator, id: u32) std.mem.Allocator.Error![]u8 { -@@ -118,9 +124,11 @@ const Served = struct { - if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair) != 0) return error.SocketPair; - s.* = .{ .srv = undefined, .client = pair[0], .route_fd = pair[1], .route = undefined, .write_ms = write_ms }; - s.srv = try server.Server.init(gpa, std.testing.io, script.engine(), s.text.text(), .{ .served_name = "m", .model_ids = &.{"m"} }, null); -+ s.srv.voices = script.voices; - if (script.output_full) s.srv.speech_inflight.store(s.srv.info.lanes, .release); - var head: [160]u8 = undefined; -- try writeAll(s.client, try std.fmt.bufPrint(&head, "POST /v1/audio/speech {s}\r\nHost: t\r\nContent-Length: {d}\r\n\r\n", .{ version, body.len })); -+ const method_path = if (script.get_voices) "GET /v1/voices" else "POST /v1/audio/speech"; -+ try writeAll(s.client, try std.fmt.bufPrint(&head, "{s} {s}\r\nHost: t\r\nContent-Length: {d}\r\n\r\n", .{ method_path, version, body.len })); - try writeAll(s.client, body); - s.route = try std.Thread.spawn(.{}, serve, .{s}); - } -@@ -389,3 +397,59 @@ test "stream needs pcm and HTTP/1.1; stream false is the whole reply" { - s.join(&script); - try std.testing.expectEqualStrings(&pcm(1), try s.readRest()); - } -+ -+test "voices: HTTP listing and reference rows use the family CLI builder" { -+ var arena = std.heap.ArenaAllocator.init(gpa); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ const family = @import("drama_prompt"); -+ const codes = [_][10]i32{.{ 7, 1, 2, 3, 4, 5, 6, 7, 8, 9 }}; -+ const refs = [_]family.Reference{.{ .transcript = "A small cloud passes.", .codes = &codes }}; -+ const voices = [_]@import("drama_voice.zig").Voice{.{ .name = "tone", .references = &refs }}; -+ const CliTokenizer = struct { -+ pub fn specialTokenId(_: @This(), _: []const u8) ?u32 { -+ return 1000; -+ } -+ pub fn encode(_: @This(), alloc: std.mem.Allocator, input: []const u8) ![]u32 { -+ var unused: u8 = 0; -+ return Bytes.encode(&unused, alloc, input, true); -+ } -+ }; -+ const expected = try family.build(a, CliTokenizer{}, "hi", &refs); -+ var script: Script = .{ .steps = &.{frame}, .voices = &voices, .expected_rows = expected }; -+ var s: Served = undefined; -+ try s.start(&script, "HTTP/1.1", "{\"model\":\"m\",\"input\":\"hi\",\"voice\":\"tone\",\"response_format\":\"pcm\"}", 2000); -+ defer s.deinit(); -+ try expectHeader(try s.readHead(), " 200 "); -+ s.join(&script); -+ try std.testing.expect(script.rows_equal); -+ try std.testing.expectEqualStrings(&pcm(1), try s.readRest()); -+ var listing_script: Script = .{ .steps = &.{}, .voices = &voices, .get_voices = true }; -+ var listing: Served = undefined; -+ try listing.start(&listing_script, "HTTP/1.1", "", 2000); -+ defer listing.deinit(); -+ try expectHeader(try listing.readHead(), " 200 "); -+ listing.join(&listing_script); -+ try std.testing.expectEqualStrings("{\"voices\": [\"default\", \"tone\"]}", try listing.readRest()); -+} -+ -+test "voices: without configuration the default prompt rows stay unchanged" { -+ var arena = std.heap.ArenaAllocator.init(gpa); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ var bytes: Bytes = .{}; -+ const tokens = try @import("drama_prompt.zig").encode(a, bytes.text(), "hi"); -+ const rows = try a.alloc([11]i32, tokens.len); -+ for (rows, tokens) |*row, token| { -+ row.* = @splat(0); -+ row[0] = @intCast(token); -+ } -+ var script: Script = .{ .steps = &.{frame}, .expected_rows = rows }; -+ var s: Served = undefined; -+ try s.start(&script, "HTTP/1.1", "{\"model\":\"m\",\"input\":\"hi\",\"response_format\":\"pcm\"}", 2000); -+ defer s.deinit(); -+ try expectHeader(try s.readHead(), " 200 "); -+ s.join(&script); -+ try std.testing.expect(script.rows_equal); -+ try std.testing.expectEqualStrings(&pcm(1), try s.readRest()); -+} diff --git a/engine/patches/0006-feat-speech-add-opt-in-sustained-silence-guard.patch b/engine/patches/0006-feat-speech-add-opt-in-sustained-silence-guard.patch deleted file mode 100644 index 1627c1e..0000000 --- a/engine/patches/0006-feat-speech-add-opt-in-sustained-silence-guard.patch +++ /dev/null @@ -1,641 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: plotarmordev <299844489+plotarmordev@users.noreply.github.com> -Date: Wed, 21 Oct 2026 08:05:41 +0800 -Subject: [PATCH 06/11] feat(speech): add opt-in sustained silence guard - -K2: net diff of pr/silence-guard 3f9c1b2f. ---- - CHANGELOG.md | 1 + - README.md | 2 + - tools/zig/test_speech_flags.py | 31 ++++++++ - zig/build/drama3_host.zig | 2 + - zig/src/core/engine_api.zig | 5 +- - zig/src/core/speech_api.zig | 4 +- - zig/src/core/speech_guard.zig | 106 ++++++++++++++++++++++++++ - zig/src/native/drama_compile.zig | 4 + - zig/src/native/drama_host.zig | 89 +++++++++++++++++++-- - zig/src/native/drama_open.zig | 8 +- - zig/src/server/chat.zig | 1 + - zig/src/server/cli.zig | 29 +++++++ - zig/src/server/main.zig | 6 +- - zig/src/server/speech.zig | 9 ++- - zig/src/server/speech_stream_test.zig | 21 ++++- - 15 files changed, 299 insertions(+), 19 deletions(-) - create mode 100644 tools/zig/test_speech_flags.py - create mode 100644 zig/src/core/speech_guard.zig - -diff --git a/CHANGELOG.md b/CHANGELOG.md -index 18e78aca..2e128a13 100644 ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -8,6 +8,7 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - - Drama 3 serving accepts `--max-frames` to bound synthesis frames, preserving the stock cap when omitted. - - `serve --allowed-controls LIST` opts into stripping unlisted bracketed speech controls before input validation and tokenization. Omission preserves the input unchanged; see [speech controls](docs/speech-controls.md) for the open-vocabulary name grammar and malformed-input policy. - - Drama speech serving accepts opt-in attested voice catalogs with `--voices`, `--default-voice` and `GET /v1/voices`, using the family's reference prompt builder. Catalog loading honors the parser's inclusive file-size bound and reports startup refusal reasons. -+- Native Drama speech can opt into `--silence-floor-dbfs` and `--silence-seconds` to keep audio through a sustained silent tail and finish as `silence`, including an HTTP finish header or stream trailer. The default is off. - - ## 1.1.1 - -diff --git a/README.md b/README.md -index 7efca45f..46031cb7 100644 ---- a/README.md -+++ b/README.md -@@ -243,6 +243,8 @@ The binary's `capabilities --json` response lists its supported flags and platfo - | `--drafter-bits 0\|4` | Drafter weight precision where the model offers a choice: 4 bits (the default), or 0 to keep bf16. Qwen3.8-27B on CUDA takes 4 only. | - | `--record DIR` | Write each stream's committed tokens, positions and tapped rows under DIR, for drafter training. CUDA Nemotron only; a single stream runs through the lane rounds, and recording stops below 25 GiB free disk. | - | `--keep-warm SECONDS` | Keep the Metal GPU active while idle for this long after a request, default 900; zero disables it. | -+| `--silence-floor-dbfs DBFS` | Drama speech only: explicit frame-power floor for the silence guard, with no default floor. Calibrate below quiet intended speech using public or built-in speech. | -+| `--silence-seconds SECONDS` | Drama speech only: stop after this continuous duration below the explicit floor, retaining audio and reporting `silence`. Unset by default: off; calibrate above intentional pauses. | - | `--parallel N` | Admit up to N requests where the engine shares lanes; `auto` is the default. | - | `--prompt-cache-gib GIB` | Retained-prefix budget, 16 GiB by default or less where memory is short; 0 disables it. | - | `--prompt-cache-over-cap` | Permit an explicit prefix budget past what 70% of RAM leaves once the server loads. | -diff --git a/tools/zig/test_speech_flags.py b/tools/zig/test_speech_flags.py -new file mode 100644 -index 00000000..d9874036 ---- /dev/null -+++ b/tools/zig/test_speech_flags.py -@@ -0,0 +1,31 @@ -+"""Speech-only flags are refused before HTTP startup.""" -+from pathlib import Path -+import os -+import subprocess -+import tempfile -+import unittest -+ -+ROOT = Path(__file__).resolve().parents[2] -+BINARY = ROOT / "zig-out/native/bin/tensorfold-native" -+MESSAGE = "--silence-floor-dbfs and --silence-seconds are for Drama speech: serve without them" -+ -+ -+@unittest.skipUnless(BINARY.is_file(), "build native before running the CLI admission test") -+class SpeechFlags(unittest.TestCase): -+ def test_refuses_before_loading(self): -+ with tempfile.TemporaryDirectory() as folder: -+ Path(folder, "config.json").write_text('{"model_type":"nemotron_h"}', encoding="utf-8") -+ for flags in (("--silence-floor-dbfs=-80",), ("--silence-seconds=0",), -+ ("--silence-floor-dbfs=-80", "--silence-seconds=1")): -+ with self.subTest(flags=flags): -+ result = subprocess.run( -+ [str(BINARY), "serve", folder, *flags], -+ env={**os.environ, "CUDA_VISIBLE_DEVICES": ""}, -+ capture_output=True, text=True, timeout=15, -+ ) -+ self.assertEqual(result.returncode, 1, result.stderr) -+ self.assertIn(MESSAGE, result.stderr) -+ -+ -+if __name__ == "__main__": -+ unittest.main() -diff --git a/zig/build/drama3_host.zig b/zig/build/drama3_host.zig -index 1da5c9ad..d63344ad 100644 ---- a/zig/build/drama3_host.zig -+++ b/zig/build/drama3_host.zig -@@ -25,6 +25,8 @@ pub fn native(b: *std.Build, step: *std.Build.Step, family: *std.Build.Module, c - mod.addImport("engine_api", api); - if (cuda) |c| mod.addImport("cuda", c); - step.dependOn(&b.addObject(.{ .name = name, .root_module = mod }).step); -+ const tests = b.addTest(.{ .root_module = mod, .filters = &.{"native silence guard:"} }); -+ step.dependOn(&b.addRunArtifact(tests).step); - } - - pub fn metal(b: *std.Build, core: *std.Build.Module, api: *std.Build.Module) void { -diff --git a/zig/src/core/engine_api.zig b/zig/src/core/engine_api.zig -index 5be157d7..3cb07045 100644 ---- a/zig/src/core/engine_api.zig -+++ b/zig/src/core/engine_api.zig -@@ -123,7 +123,8 @@ pub const SpeechResult = @import("speech_api.zig").SpeechResult; - pub const SpeechInfo = @import("speech_api.zig").SpeechInfo; - pub const SpeechSubmitError = error{ Closed, Busy, InvalidRequest, Unsupported }; - --pub const Reason = enum { stop, length, cancelled, failed }; -+pub const Reason = enum { stop, length, cancelled, failed, silence }; -+pub const speech_guard = @import("speech_guard.zig"); - - /// What a finished reply's rounds did, for its runtime fields and /metrics. - pub const Stats = struct { -@@ -241,6 +242,7 @@ pub const Open = struct { - model_type: []const u8, - context: ?i64 = null, - max_frames: ?u32 = null, -+ silence: speech_guard.Config = .{}, - lanes: u32 = 8, - /// --parallel named a number: an engine whose memory fits fewer streams refuses instead of serving fewer. - lanes_fixed: bool = false, -@@ -495,6 +497,7 @@ pub const originals = @import("originals.zig"); - pub const save_guard = @import("save_guard"); - - test { -+ _ = speech_guard; - _ = @import("lane_host.zig"); - _ = @import("lane_host_test.zig"); - _ = @import("lane_rounds_test.zig"); -diff --git a/zig/src/core/speech_api.zig b/zig/src/core/speech_api.zig -index 5ba0b254..a6bc2b61 100644 ---- a/zig/src/core/speech_api.zig -+++ b/zig/src/core/speech_api.zig -@@ -1,5 +1,5 @@ - /// Mono PCM written before a successful speech request's finished event. --pub const SpeechResult = struct { samples: usize = 0 }; -+pub const SpeechResult = struct { samples: usize = 0, reason: @import("engine_api.zig").Reason = .stop }; - - /// Caller-owned until finished, even on cancellation; a streamed request's pcm[0..n] is the caller's after ``audio`` n. - pub const SpeechRequest = struct { -@@ -12,7 +12,7 @@ pub const SpeechRequest = struct { - temperature: f32 = 1, - top_p: f32 = 0.9, - top_k: u32 = 30, -- /// True decodes while generating and publishes audio prefixes; false decodes after EOS or the frame limit. -+ /// Publishes decoded prefixes; whole replies also decode incrementally when a speech guard is enabled. - stream: bool = false, - }; - -diff --git a/zig/src/core/speech_guard.zig b/zig/src/core/speech_guard.zig -new file mode 100644 -index 00000000..20bb1690 ---- /dev/null -+++ b/zig/src/core/speech_guard.zig -@@ -0,0 +1,106 @@ -+//! Frame-level silence accounting shared by native speech backends. -+const std = @import("std"); -+ -+pub const Config = struct { -+ floor_dbfs: ?f64 = null, -+ seconds: ?f64 = null, -+ -+ pub fn validate(c: Config) !void { -+ if (c.seconds) |seconds| { -+ if (!std.math.isFinite(seconds) or seconds < 0) return error.BadSilenceSeconds; -+ } -+ if (c.floor_dbfs) |floor| { -+ if (!std.math.isFinite(floor) or floor > 0 or std.math.pow(f64, 10, floor / 10) == 0) -+ return error.BadSilenceFloor; -+ } else if ((c.seconds orelse 0) > 0) return error.MissingSilenceFloor; -+ } -+ -+ pub fn enabled(c: Config) bool { -+ return (c.seconds orelse 0) > 0 and c.floor_dbfs != null; -+ } -+ -+ pub fn refusal(c: Config, model_type: []const u8) ?[]const u8 { -+ if (!std.mem.eql(u8, model_type, "fish_omni") and -+ (c.floor_dbfs != null or c.seconds != null)) -+ return "--silence-floor-dbfs and --silence-seconds are for Drama speech: serve without them"; -+ return null; -+ } -+}; -+ -+pub const Guard = struct { -+ floor_power: f64 = 0, -+ limit: f64 = 0, -+ silent_samples: usize = 0, -+ voiced_samples: usize = 0, -+ tripped: bool = false, -+ -+ pub fn init(c: Config, sample_rate: u32) Guard { -+ if (!c.enabled()) return .{}; -+ return .{ -+ .floor_power = std.math.pow(f64, 10, c.floor_dbfs.? / 10), -+ .limit = c.seconds.? * @as(f64, @floatFromInt(sample_rate)), -+ }; -+ } -+ -+ // Account before publication: the HTTP owner may quantize published PCM in place. -+ pub fn hear(g: *Guard, pcm: []const f32, frame_length: usize) usize { -+ if (g.limit == 0) return pcm.len; -+ if (g.tripped) return 0; -+ std.debug.assert(frame_length > 0 and pcm.len % frame_length == 0); -+ var end: usize = 0; -+ while (end < pcm.len) { -+ var power: f64 = 0; -+ for (pcm[end..][0..frame_length]) |sample| { -+ const x: f64 = sample; -+ power += x * x; -+ } -+ end += frame_length; -+ if (power < g.floor_power * @as(f64, @floatFromInt(frame_length))) { -+ g.silent_samples += frame_length; -+ } else { -+ g.silent_samples = 0; -+ g.voiced_samples += frame_length; -+ } -+ if (@as(f64, @floatFromInt(g.silent_samples)) >= g.limit) { -+ g.tripped = true; -+ return end; -+ } -+ } -+ return end; -+ } -+}; -+ -+test "silence tail cuts at a frame boundary and latches before later speech" { -+ var guard = Guard.init(.{ .floor_dbfs = -20, .seconds = 0.5 }, 8); -+ const pcm = [_]f32{ 0.25, -0.25, 0, 0, 0, 0, 0.25, -0.25 }; -+ try std.testing.expectEqual(@as(usize, 6), guard.hear(&pcm, 2)); -+ try std.testing.expect(guard.tripped); -+ try std.testing.expectEqual(@as(usize, 2), guard.voiced_samples); -+ try std.testing.expectEqual(@as(usize, 0), guard.hear(&pcm, 2)); -+} -+ -+test "quiet voiced frames reset silence and disabled guard preserves all samples" { -+ var guard = Guard.init(.{ .floor_dbfs = -80, .seconds = 0.5 }, 8); -+ const pcm = [_]f32{ 0, 0, 0.001, -0.001, 0, 0, 0.001, -0.001 }; -+ try std.testing.expectEqual(pcm.len, guard.hear(&pcm, 2)); -+ try std.testing.expect(!guard.tripped); -+ var off = Guard.init(.{}, 8); -+ const silent: [16]f32 = @splat(0); -+ try std.testing.expectEqual(silent.len, off.hear(&silent, 2)); -+ try std.testing.expect(!off.tripped); -+} -+ -+test "silence configuration requires explicit finite thresholds" { -+ try (Config{}).validate(); -+ try std.testing.expectError(error.MissingSilenceFloor, (Config{ .seconds = 1 }).validate()); -+ try std.testing.expectError(error.BadSilenceSeconds, (Config{ .seconds = std.math.inf(f64) }).validate()); -+ try std.testing.expectError(error.BadSilenceFloor, (Config{ .floor_dbfs = 1 }).validate()); -+} -+ -+test "silence flags are refused on text even when explicitly disabled" { -+ for ([_]Config{ .{ .floor_dbfs = -80 }, .{ .seconds = 0 }, .{ .seconds = 1 } }) |config| { -+ try std.testing.expect(config.refusal("nemotron_h") != null); -+ try std.testing.expect(config.refusal("fish_omni") == null); -+ } -+ try std.testing.expect((Config{}).refusal("nemotron_h") == null); -+} -diff --git a/zig/src/native/drama_compile.zig b/zig/src/native/drama_compile.zig -index d2c6520d..c01e96d7 100644 ---- a/zig/src/native/drama_compile.zig -+++ b/zig/src/native/drama_compile.zig -@@ -10,3 +10,7 @@ export fn compileNativeDrama(io: *const std.Io, dir: [*:0]const u8, r: *const ap - opened.engine.drain(); - } - fn event(_: *anyopaque, _: api.Id, _: *const api.Event) void {} -+ -+test { -+ _ = host; -+} -diff --git a/zig/src/native/drama_host.zig b/zig/src/native/drama_host.zig -index 7a415d26..2125bd64 100644 ---- a/zig/src/native/drama_host.zig -+++ b/zig/src/native/drama_host.zig -@@ -18,11 +18,18 @@ const Job = struct { - sink: api.Sink, - state: d3.kv_state.State = .{}, - samples: usize = 0, -+ guard: api.speech_guard.Guard = .{}, -+ frame_length: usize = 0, -+ -+ fn incremental(j: *const Job) bool { -+ return j.request.stream or j.guard.limit > 0; -+ } - }; - pub const Options = struct { - context: u32 = 8192, - device: u32 = 0, - lanes: u32 = 2, -+ silence: api.speech_guard.Config = .{}, - prefill_rows: u32 = if (is_cuda) 128 else 1, - max_frames: ?u32 = null, - }; -@@ -35,6 +42,7 @@ const Host = struct { - sessions: [rounds.capacity]Session = undefined, - jobs: [rounds.capacity]?Job = @splat(null), - info_: api.Info, -+ silence: api.speech_guard.Config, - mutex: std.Io.Mutex = .init, - wake: std.Io.Condition = .init, - worker: ?std.Thread = null, -@@ -83,7 +91,13 @@ const Host = struct { - for (&h.jobs) |*job| if (job.*) |*j| if (j.id == id) return error.InvalidRequest; - const lane = try h.queue.admit(); - r.result.* = .{}; -- h.jobs[lane] = .{ .id = id, .request = r, .sink = sink }; -+ h.jobs[lane] = .{ -+ .id = id, -+ .request = r, -+ .sink = sink, -+ .guard = .init(h.silence, h.info_.speech.?.sample_rate), -+ .frame_length = h.info_.speech.?.frame_length, -+ }; - h.wake.signal(h.io); - } - fn cancel(p: *anyopaque, id: api.Id) void { -@@ -179,21 +193,23 @@ const Host = struct { - var frame: d3.model.Frame = undefined; - try s.e.download(s.e.frames_out + j.state.frames * 11 * 4, std.mem.asBytes(&frame)); - if (frame[0] == s.e.c.ids.eos) { -- try decode.flush(s.t, r.stream, j.state.frames, j.state.frames, true, @as(d3.tts.Sink, .{ .ctx = j, .audio = copyAudio })); -- return h.finish(i, .stop, ""); -+ try decode.flush(s.t, j.incremental(), j.state.frames, j.state.frames, true, @as(d3.tts.Sink, .{ .ctx = j, .audio = copyAudio })); -+ return h.finish(i, if (j.guard.tripped) .silence else .stop, ""); - } - const from = j.state.frames; - j.state.frames += 1; -- try decode.flush(s.t, r.stream, from, j.state.frames, j.state.frames == r.max_frames, @as(d3.tts.Sink, .{ .ctx = j, .audio = copyAudio })); -+ try decode.flush(s.t, j.incremental(), from, j.state.frames, j.state.frames == r.max_frames, @as(d3.tts.Sink, .{ .ctx = j, .audio = copyAudio })); - if (r.stream) j.sink.event(j.sink.ctx, j.id, &.{ .audio = j.samples }); -+ if (j.guard.tripped) return h.finish(i, .silence, ""); - if (j.state.frames == r.max_frames) h.finish(i, .length, ""); - } - fn copyAudio(p: *anyopaque, samples: []const f32) !void { - const j: *Job = @ptrCast(@alignCast(p)); - if (samples.len > j.request.pcm.len - j.samples) return error.AudioOverflow; - for (samples) |x| if (!std.math.isFinite(x)) return error.NonFiniteAudio; -- @memcpy(j.request.pcm[j.samples..][0..samples.len], samples); -- j.samples += samples.len; -+ const kept = j.guard.hear(samples, j.frame_length); -+ @memcpy(j.request.pcm[j.samples..][0..kept], samples[0..kept]); -+ j.samples += kept; - } - fn finish(h: *Host, lane: usize, reason: api.Reason, message: []const u8) void { - var state: completion.State = .{}; -@@ -203,7 +219,9 @@ const Host = struct { - h.lock(); - if (!completed) h.failed.closed = true; - const j = h.jobs[lane].?; -- if (final_reason == .stop or final_reason == .length) j.request.result.samples = j.samples; -+ if (final_reason == .stop or final_reason == .length or final_reason == .silence) -+ j.request.result.samples = j.samples; -+ j.request.result.reason = final_reason; - h.jobs[lane] = null; - h.queue.release(lane); - h.unlock(); -@@ -250,9 +268,17 @@ pub fn open(gpa: std.mem.Allocator, io: std.Io, dir: []const u8, _: []const u8, - if (o.context < 1025 or o.context > 8192) return error.BadDramaContext; - try (d3.Options{ .max_rows = o.prefill_rows, .attn_chunk = o.prefill_rows }).validatePrefill(); - const budget = try @import("drama_budget.zig").budget(o.context, o.max_frames); -+ try o.silence.validate(); - const h = try gpa.create(Host); - errdefer gpa.destroy(h); -- h.* = .{ .gpa = gpa, .io = io, .limit = undefined, .queue = try rounds.Queue.init(o.lanes), .info_ = .{} }; -+ h.* = .{ -+ .gpa = gpa, -+ .io = io, -+ .limit = undefined, -+ .queue = try rounds.Queue.init(o.lanes), -+ .info_ = .{}, -+ .silence = o.silence, -+ }; - try h.limit.init(gpa, io, o.device); - errdefer h.limit.deinit(); - var made: usize = 0; -@@ -287,5 +313,52 @@ pub fn open(gpa: std.mem.Allocator, io: std.Io, dir: []const u8, _: []const u8, - .speech = .{ .sample_rate = 44100, .frame_length = 2048, .max_frames = frame_cap, .max_prompt_tokens = budget.prompt }, - }; - h.worker = try std.Thread.spawn(.{}, Host.run, .{h}); -+ if (o.silence.enabled()) -+ std.log.info("drama3: silence guard on: a reply ends after {d} s below {d} dBFS", .{ o.silence.seconds.?, o.silence.floor_dbfs.? }); - return .{ .engine = .{ .ctx = h, .vtable = &Host.vtable }, .ctx = h, .close = Host.close }; - } -+ -+test "native silence guard: whole and stream share decode cuts; off preserves PCM" { -+ const Fake = struct { -+ calls: usize = 0, -+ const pcm = [_]f32{ 0.25, -0.25, 0, 0, 0, 0, 0.25, -0.25 }; -+ pub fn flush(t: *@This(), from: usize, to: usize, sink: d3.tts.Sink) !void { -+ t.calls += 1; -+ try sink.audio(sink.ctx, pcm[from * 2 .. to * 2]); -+ } -+ fn event(_: *anyopaque, _: api.Id, _: *const api.Event) void {} -+ }; -+ for ([_]bool{ false, true }) |enabled| { -+ for ([_]bool{ false, true }) |stream| { -+ var pcm: [Fake.pcm.len]f32 = undefined; -+ var result: api.SpeechResult = .{}; -+ const r: api.SpeechRequest = .{ -+ .prompt = &.{}, -+ .pcm = &pcm, -+ .result = &result, -+ .seed = 0, -+ .stream = stream, -+ }; -+ var t: Fake = .{}; -+ var j: Job = .{ -+ .id = 0, -+ .request = &r, -+ .sink = .{ .ctx = &t, .event = Fake.event }, -+ .guard = .init(if (enabled) .{ .floor_dbfs = -20, .seconds = 0.5 } else .{}, 8), -+ .frame_length = 2, -+ }; -+ const sink: d3.tts.Sink = .{ .ctx = &j, .audio = Host.copyAudio }; -+ for (0..Fake.pcm.len / 2) |i| { -+ try decode.flush(&t, j.incremental(), i, i + 1, false, sink); -+ if (j.guard.tripped) break; -+ } -+ if (!j.guard.tripped) -+ try decode.flush(&t, j.incremental(), Fake.pcm.len / 2, Fake.pcm.len / 2, true, sink); -+ const kept: usize = if (enabled) 6 else Fake.pcm.len; -+ try std.testing.expectEqual(kept, j.samples); -+ try std.testing.expectEqual(enabled, j.guard.tripped); -+ try std.testing.expectEqualSlices(f32, Fake.pcm[0..kept], pcm[0..j.samples]); -+ if (!enabled and !stream) try std.testing.expectEqual(@as(usize, 1), t.calls); -+ } -+ } -+} -diff --git a/zig/src/native/drama_open.zig b/zig/src/native/drama_open.zig -index 77ed79f7..aaf18327 100644 ---- a/zig/src/native/drama_open.zig -+++ b/zig/src/native/drama_open.zig -@@ -48,6 +48,7 @@ pub fn open(a: Allocator, gpa: Allocator, io: std.Io, o: api.Open, problem: *[]c - .lanes = if (o.lanes_fixed) o.lanes else 2, - .prefill_rows = prefill_rows, - .max_frames = o.max_frames, -+ .silence = o.silence, - }) catch |e| { - problem.* = try std.fmt.allocPrint(a, "the native Drama 3 CUDA engine could not load the checkpoint ({s})", .{@errorName(e)}); - return null; -@@ -71,7 +72,12 @@ pub fn openMetal(a: Allocator, gpa: Allocator, io: std.Io, o: api.Open, problem: - problem.* = why; - return null; - } -- return drama_host.open(gpa, io, o.dir, "", .{ .context = @intCast(window), .lanes = if (o.lanes_fixed) o.lanes else 2, .max_frames = o.max_frames }) catch |err| { -+ return drama_host.open(gpa, io, o.dir, "", .{ -+ .context = @intCast(window), -+ .lanes = if (o.lanes_fixed) o.lanes else 2, -+ .max_frames = o.max_frames, -+ .silence = o.silence, -+ }) catch |err| { - problem.* = try std.fmt.allocPrint(a, "Drama 3 Metal checkpoint load failed ({s})", .{@errorName(err)}); - return null; - }; -diff --git a/zig/src/server/chat.zig b/zig/src/server/chat.zig -index 0bacc0e6..dcd1cd2d 100644 ---- a/zig/src/server/chat.zig -+++ b/zig/src/server/chat.zig -@@ -424,6 +424,7 @@ const Generation = struct { - const reason: []const u8 = g.reason orelse switch (m.reason) { - .stop => "stop", - .length => "length", -+ .silence => return cx.other("the text engine returned a speech-only finish reason"), - .cancelled => "cancelled", - .failed => return cx.other(if (m.message.len > 0) try a.dupe(u8, m.message) else "the reply failed"), - }; -diff --git a/zig/src/server/cli.zig b/zig/src/server/cli.zig -index 45b57fd6..308eebf2 100644 ---- a/zig/src/server/cli.zig -+++ b/zig/src/server/cli.zig -@@ -77,6 +77,8 @@ pub const flags = [_]Flag{ - .{ .name = "--loop-guard", .kind = .store_true, .native = true }, - .{ .name = "--no-drafts", .kind = .store_true, .native = true }, - .{ .name = "--keep-warm", .native = true }, -+ .{ .name = "--silence-floor-dbfs", .native = true }, -+ .{ .name = "--silence-seconds", .native = true }, - .{ .name = "--compact-at", .native = true }, - .{ .name = "--compact-keep", .native = true }, - .{ .name = "--compact-memory", .native = true }, -@@ -155,6 +157,7 @@ pub const Args = struct { - dashboard: bool = false, - context: ?i64 = null, - max_frames: ?u32 = null, -+ silence: api.speech_guard.Config = .{}, - speed_up: ?[]const u8 = null, // speed-up mode: this Mac's settings for the two-Mac link (Flash Next and the GLMs) - prompt_cache_gib: ?f64 = null, // kept prompt states' budget (0: none; null: what 70% of RAM leaves past the loaded server) - prompt_cache_over_cap: bool = false, // a --prompt-cache-gib past that is kept, not refused -@@ -261,6 +264,8 @@ pub fn parse(a: Allocator, argv: []const []const u8, u: *Usage) error{ Usage, Ou - } - out.model = model orelse return fail(u, a, "the following arguments are required: model", .{}); - try ranks(u, a, out); -+ out.silence.validate() catch |err| -+ return fail(u, a, "invalid silence guard configuration: {s}", .{@errorName(err)}); - if (out.vision_urls and !out.vision) return fail(u, a, "--vision-urls needs --vision", .{}); - out.alias = repeated.alias.items; - out.api_key = repeated.keys.items; -@@ -299,6 +304,14 @@ fn apply(a: Allocator, out: *Args, name: []const u8, value: ?[]const u8, u: *Usa - return fail(u, a, "argument --allowed-controls: expected comma-separated UTF-8 names without square brackets: '{s}'", .{v}); - return; - } -+ if (is(name, "--silence-floor-dbfs")) { -+ out.silence.floor_dbfs = try float(u, a, name, v); -+ return; -+ } -+ if (is(name, "--silence-seconds")) { -+ out.silence.seconds = try float(u, a, name, v); -+ return; -+ } - if (is(name, "--allowed-host")) { - if (!@import("request_guard.zig").validHost(v)) return fail(u, a, "argument --allowed-host: expected a hostname or bracketed IP without a port: '{s}'", .{v}); - return repeated.hosts.append(a, v); -@@ -463,6 +476,7 @@ pub fn request(a: Allocator, dir: []const u8, model_type: []const u8, args: Args - .model_type = model_type, - .context = args.context, - .max_frames = args.max_frames, -+ .silence = args.silence, - .lanes = parallel(args.parallel) orelse 8, - .lanes_fixed = parallelFixed(args.parallel), - .drafts = !args.no_drafts, -@@ -591,3 +605,18 @@ test "the GPU engine's flags: refused and unlisted unless the backend serves the - for ([_][2][]const u8{ .{ "--rank", "-1" }, .{ "--master-port", "70000" }, .{ "--mtp-drafts", "65" }, .{ "--mtp-confidence", "1.5" }, .{ "--checkpoint-slots", "-1" }, .{ "--policy", "matrix" } }) |g| try std.testing.expectError(error.Usage, gpuFlag(a, &args, g[0], g[1], &u)); - try std.testing.expect(!try gpuFlag(a, &args, "--port", "1", &u)); - } -+ -+test "silence guard flags are opt-in and reach native open options" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ var usage: Usage = .{}; -+ try std.testing.expect(!(try parse(a, &.{"m"}, &usage)).silence.enabled()); -+ try std.testing.expect((try parse(a, &.{ "m", "--silence-seconds=0" }, &usage)).silence.refusal("nemotron_h") != null); -+ const args = try parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-seconds", "0.5" }, &usage); -+ const opened = try request(a, "m", "drama3", args); -+ try std.testing.expectEqual(args.silence, opened.silence); -+ try std.testing.expect(opened.silence.enabled()); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-seconds", "1" }, &usage)); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-seconds", "nan" }, &usage)); -+} -diff --git a/zig/src/server/main.zig b/zig/src/server/main.zig -index 14dae5c1..f80b798f 100644 ---- a/zig/src/server/main.zig -+++ b/zig/src/server/main.zig -@@ -24,8 +24,9 @@ const usage_line = "usage: tensorfold serve [-h] [--host HOST] [--port PORT] [-- - " [--temperature TEMPERATURE] [--top-p TOP_P] [--top-k TOP_K] [--min-p MIN_P] [--prompt-seed]" ++ - " [--thinking | --no-thinking] [--reasoning-effort {low,medium,high,xhigh}]" ++ - " [--thinking-budget THINKING_BUDGET] [--loop-guard] [--no-drafts] [--drafter DRAFTER]" ++ -- " [--drafter-bits {0,4}] [--record RECORD] [--keep-warm SECONDS] [--compact-at COMPACT_AT]" ++ -- " [--compact-keep COMPACT_KEEP]" ++ -+ " [--drafter-bits {0,4}] [--record RECORD] [--keep-warm SECONDS]" ++ -+ " [--silence-floor-dbfs DBFS] [--silence-seconds SECONDS]" ++ -+ " [--compact-at COMPACT_AT] [--compact-keep COMPACT_KEEP]" ++ - " [--compact-memory COMPACT_MEMORY] [--slide] [--slide-graph SLIDE_GRAPH] [--parallel PARALLEL]" ++ - " [--cortheon] [--no-update-check] [--backend {auto,mlx,cuda}] [--device DEVICE]" ++ - " [--segments SEGMENTS] model\n"; -@@ -102,6 +103,7 @@ pub fn main(init: std.process.Init) !u8 { - if (e == error.OutOfMemory) return e; - return fail(problem); - }; -+ if (args.silence.refusal(model_type)) |why| return fail(why); - // the model folder held while this process reads it, so a rollback refuses it: exclusive with --slide, else shared - var unheld: []const u8 = ""; - const held = api.originals.serving(a, dir, args.slide, &unheld) catch |e| switch (e) { -diff --git a/zig/src/server/speech.zig b/zig/src/server/speech.zig -index c27b0e07..02bb85cf 100644 ---- a/zig/src/server/speech.zig -+++ b/zig/src/server/speech.zig -@@ -198,10 +198,10 @@ fn left(srv: *Server, c: *Conn, id: []const u8, prompt_len: usize, received: i96 - log.line("{s}", .{log.ended(&buf, id, null, prompt_len, 0, clock.seconds(clock.nowNs(srv.io) - received))}); - } - --/// Why a finished synthesis has no complete audio, or null; clipped audio (length) is sent with X-Finish-Reason. -+/// Why audio is incomplete, or null; length and silence retain audio with X-Finish-Reason. - fn why(box: *const Mailbox, samples: usize, capacity: usize) ?[]const u8 { - return switch (box.reason) { -- .stop, .length => if (samples == 0 or samples > capacity) "the engine finished without complete audio" else null, -+ .stop, .length, .silence => if (samples == 0 or samples > capacity) "the engine finished without complete audio" else null, - .cancelled => "speech synthesis was cancelled", - .failed => if (box.message.len > 0) box.message else "speech synthesis failed", - }; -@@ -218,7 +218,7 @@ fn streamReply(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechIn - log.line("speech {s} stream ended incomplete after samples={d}: {s}", .{ id, sent.samples, message }); - return; - } -- c.writeAll(if (box.reason == .length) length_chunk else last_chunk) catch { -+ c.writeAll(if (box.reason == .silence) silence_chunk else if (box.reason == .length) length_chunk else last_chunk) catch { - c.close = true; - return; - }; -@@ -301,9 +301,10 @@ fn startStream(c: *Conn, sample_rate: u32) error{Closed}!void { - return c.finish(""); - } - --/// Ends a complete stream; audio clipped at the frame limit ends with the X-Finish-Reason trailer instead. -+/// Ends a complete stream; length and silence use an X-Finish-Reason trailer instead. - const last_chunk = "0\r\n\r\n"; - const length_chunk = "0\r\nX-Finish-Reason: length\r\n\r\n"; -+const silence_chunk = "0\r\nX-Finish-Reason: silence\r\n\r\n"; - - fn refuse(c: *Conn, cx: *Cx, id: []const u8, e: errors.Refused) void { - if (e == error.OutOfMemory) { -diff --git a/zig/src/server/speech_stream_test.zig b/zig/src/server/speech_stream_test.zig -index ddd94a07..aee4fabe 100644 ---- a/zig/src/server/speech_stream_test.zig -+++ b/zig/src/server/speech_stream_test.zig -@@ -51,7 +51,8 @@ const Script = struct { - if (request.stream) sink.event(sink.ctx, id, &.{ .audio = n }); - done = n; - } -- if (e.reason == .stop or e.reason == .length) request.result.samples = done; -+ if (e.reason == .stop or e.reason == .length or e.reason == .silence) request.result.samples = done; -+ request.result.reason = e.reason; - sink.event(sink.ctx, id, &.{ .finished = .{ .reason = e.reason, .message = e.message } }); - } - fn cancel(ctx: *anyopaque, _: api.Id) void { -@@ -453,3 +454,21 @@ test "voices: without configuration the default prompt rows stay unchanged" { - try std.testing.expect(script.rows_equal); - try std.testing.expectEqualStrings(&pcm(1), try s.readRest()); - } -+ -+test "silence keeps audio with a whole header or stream trailer" { -+ for ([_]bool{ false, true }) |stream| { -+ var script: Script = .{ .steps = &.{frame}, .reason = .silence, .inline_ = true }; -+ var s: Served = undefined; -+ const whole = "{\"model\":\"m\",\"input\":\"hi\",\"response_format\":\"pcm\"}"; -+ try s.start(&script, "HTTP/1.1", if (stream) stream_body else whole, 2000); -+ defer s.deinit(); -+ const head = try s.readHead(); -+ try expectHeader(head, "HTTP/1.1 200"); -+ try expectHeader(head, if (stream) "Trailer: X-Finish-Reason\r\n" else "X-Finish-Reason: silence\r\n"); -+ const rest = try s.readRest(); -+ s.join(&script); -+ if (stream) { -+ try std.testing.expectEqualStrings("1000\r\n" ++ pcm(1) ++ "\r\n0\r\nX-Finish-Reason: silence\r\n\r\n", rest); -+ } else try std.testing.expectEqualStrings(&pcm(1), rest); -+ } -+} diff --git a/engine/patches/0007-feat-speech-add-opt-in-silence-onset-rule-to-the-sil.patch b/engine/patches/0007-feat-speech-add-opt-in-silence-onset-rule-to-the-sil.patch deleted file mode 100644 index 9a84697..0000000 --- a/engine/patches/0007-feat-speech-add-opt-in-silence-onset-rule-to-the-sil.patch +++ /dev/null @@ -1,342 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: plotarmordev <299844489+plotarmordev@users.noreply.github.com> -Date: Wed, 21 Oct 2026 08:05:42 +0800 -Subject: [PATCH 07/11] feat(speech): add opt-in silence onset rule to the - silence guard - -K3: net diff of pr/silence-onset 736d754d over K2. ---- - CHANGELOG.md | 2 + - README.md | 2 + - tools/zig/test_speech_flags.py | 6 +- - zig/src/core/speech_guard.zig | 54 ++++++++++++++++-- - zig/src/native/drama_host.zig | 101 ++++++++++++++++++++------------- - zig/src/server/cli.zig | 8 ++- - zig/src/server/main.zig | 1 + - 7 files changed, 127 insertions(+), 47 deletions(-) - -diff --git a/CHANGELOG.md b/CHANGELOG.md -index 2e128a13..af7b090a 100644 ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -8,6 +8,8 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - - Drama 3 serving accepts `--max-frames` to bound synthesis frames, preserving the stock cap when omitted. - - `serve --allowed-controls LIST` opts into stripping unlisted bracketed speech controls before input validation and tokenization. Omission preserves the input unchanged; see [speech controls](docs/speech-controls.md) for the open-vocabulary name grammar and malformed-input policy. - - Drama speech serving accepts opt-in attested voice catalogs with `--voices`, `--default-voice` and `GET /v1/voices`, using the family's reference prompt builder. Catalog loading honors the parser's inclusive file-size bound and reports startup refusal reasons. -+- Native Drama speech can opt into `--silence-onset-s` with `--silence-onset-voiced-s` to end a synthesis as `silence` once that many seconds run silent while it has voiced less than the given minimum. Both are off unless given together, and need the silence guard. -+ - - Native Drama speech can opt into `--silence-floor-dbfs` and `--silence-seconds` to keep audio through a sustained silent tail and finish as `silence`, including an HTTP finish header or stream trailer. The default is off. - - ## 1.1.1 -diff --git a/README.md b/README.md -index 46031cb7..1652df0b 100644 ---- a/README.md -+++ b/README.md -@@ -245,6 +245,8 @@ The binary's `capabilities --json` response lists its supported flags and platfo - | `--keep-warm SECONDS` | Keep the Metal GPU active while idle for this long after a request, default 900; zero disables it. | - | `--silence-floor-dbfs DBFS` | Drama speech only: explicit frame-power floor for the silence guard, with no default floor. Calibrate below quiet intended speech using public or built-in speech. | - | `--silence-seconds SECONDS` | Drama speech only: stop after this continuous duration below the explicit floor, retaining audio and reporting `silence`. Unset by default: off; calibrate above intentional pauses. | -+| `--silence-onset-s SECONDS` | Drama speech only: with the silence guard on, stop once this many seconds run below its floor while the synthesis has voiced less than `--silence-onset-voiced-s`, retaining audio and reporting `silence`. Off unless given; must be below `--silence-seconds`. | -+| `--silence-onset-voiced-s SECONDS` | Drama speech only: the voiced seconds below which `--silence-onset-s` applies; required with it, with no default. Calibrate on public or built-in speech. | - | `--parallel N` | Admit up to N requests where the engine shares lanes; `auto` is the default. | - | `--prompt-cache-gib GIB` | Retained-prefix budget, 16 GiB by default or less where memory is short; 0 disables it. | - | `--prompt-cache-over-cap` | Permit an explicit prefix budget past what 70% of RAM leaves once the server loads. | -diff --git a/tools/zig/test_speech_flags.py b/tools/zig/test_speech_flags.py -index d9874036..591e540d 100644 ---- a/tools/zig/test_speech_flags.py -+++ b/tools/zig/test_speech_flags.py -@@ -7,7 +7,7 @@ import unittest - - ROOT = Path(__file__).resolve().parents[2] - BINARY = ROOT / "zig-out/native/bin/tensorfold-native" --MESSAGE = "--silence-floor-dbfs and --silence-seconds are for Drama speech: serve without them" -+MESSAGE = "--silence-floor-dbfs, --silence-seconds, --silence-onset-s and --silence-onset-voiced-s are for Drama speech: serve without them" - - - @unittest.skipUnless(BINARY.is_file(), "build native before running the CLI admission test") -@@ -16,7 +16,9 @@ class SpeechFlags(unittest.TestCase): - with tempfile.TemporaryDirectory() as folder: - Path(folder, "config.json").write_text('{"model_type":"nemotron_h"}', encoding="utf-8") - for flags in (("--silence-floor-dbfs=-80",), ("--silence-seconds=0",), -- ("--silence-floor-dbfs=-80", "--silence-seconds=1")): -+ ("--silence-floor-dbfs=-80", "--silence-seconds=1"), -+ ("--silence-floor-dbfs=-80", "--silence-seconds=2", "--silence-onset-s=1", -+ "--silence-onset-voiced-s=0.5")): - with self.subTest(flags=flags): - result = subprocess.run( - [str(BINARY), "serve", folder, *flags], -diff --git a/zig/src/core/speech_guard.zig b/zig/src/core/speech_guard.zig -index 20bb1690..424a5bc6 100644 ---- a/zig/src/core/speech_guard.zig -+++ b/zig/src/core/speech_guard.zig -@@ -4,6 +4,8 @@ const std = @import("std"); - pub const Config = struct { - floor_dbfs: ?f64 = null, - seconds: ?f64 = null, -+ onset_seconds: ?f64 = null, -+ onset_voiced_seconds: ?f64 = null, - - pub fn validate(c: Config) !void { - if (c.seconds) |seconds| { -@@ -13,6 +15,11 @@ pub const Config = struct { - if (!std.math.isFinite(floor) or floor > 0 or std.math.pow(f64, 10, floor / 10) == 0) - return error.BadSilenceFloor; - } else if ((c.seconds orelse 0) > 0) return error.MissingSilenceFloor; -+ if (c.onset_seconds == null and c.onset_voiced_seconds == null) return; -+ const onset = c.onset_seconds orelse return error.BadSilenceOnset; -+ const voiced = c.onset_voiced_seconds orelse return error.BadSilenceOnsetVoiced; -+ if (!(onset > 0 and onset < (c.seconds orelse 0))) return error.BadSilenceOnset; -+ if (!(voiced > 0 and std.math.isFinite(voiced))) return error.BadSilenceOnsetVoiced; - } - - pub fn enabled(c: Config) bool { -@@ -21,24 +28,36 @@ pub const Config = struct { - - pub fn refusal(c: Config, model_type: []const u8) ?[]const u8 { - if (!std.mem.eql(u8, model_type, "fish_omni") and -- (c.floor_dbfs != null or c.seconds != null)) -- return "--silence-floor-dbfs and --silence-seconds are for Drama speech: serve without them"; -+ (c.floor_dbfs != null or c.seconds != null or c.onset_seconds != null or c.onset_voiced_seconds != null)) -+ return "--silence-floor-dbfs, --silence-seconds, --silence-onset-s and --silence-onset-voiced-s are for Drama speech: serve without them"; - return null; - } -+ -+ /// The startup sentence's rules for an enabled guard: the silent tail, then the onset rule when set. -+ pub fn format(c: Config, w: *std.Io.Writer) std.Io.Writer.Error!void { -+ try w.print("a reply ends after {d} s below {d} dBFS", .{ c.seconds.?, c.floor_dbfs.? }); -+ if (c.onset_seconds) |onset| try w.print(", or after {d} s below it with under {d} s voiced", .{ onset, c.onset_voiced_seconds.? }); -+ } - }; - - pub const Guard = struct { - floor_power: f64 = 0, - limit: f64 = 0, -+ /// Silent samples that end a synthesis voiced for fewer than `onset_voiced` samples. -+ onset_limit: f64 = 0, -+ onset_voiced: f64 = 0, - silent_samples: usize = 0, - voiced_samples: usize = 0, - tripped: bool = false, - - pub fn init(c: Config, sample_rate: u32) Guard { - if (!c.enabled()) return .{}; -+ const rate: f64 = @floatFromInt(sample_rate); - return .{ - .floor_power = std.math.pow(f64, 10, c.floor_dbfs.? / 10), -- .limit = c.seconds.? * @as(f64, @floatFromInt(sample_rate)), -+ .limit = c.seconds.? * rate, -+ .onset_limit = (c.onset_seconds orelse 0) * rate, -+ .onset_voiced = (c.onset_voiced_seconds orelse 0) * rate, - }; - } - -@@ -61,7 +80,8 @@ pub const Guard = struct { - g.silent_samples = 0; - g.voiced_samples += frame_length; - } -- if (@as(f64, @floatFromInt(g.silent_samples)) >= g.limit) { -+ const silent: f64 = @floatFromInt(g.silent_samples); -+ if (silent >= g.limit or (silent >= g.onset_limit and @as(f64, @floatFromInt(g.voiced_samples)) < g.onset_voiced)) { - g.tripped = true; - return end; - } -@@ -95,12 +115,36 @@ test "silence configuration requires explicit finite thresholds" { - try std.testing.expectError(error.MissingSilenceFloor, (Config{ .seconds = 1 }).validate()); - try std.testing.expectError(error.BadSilenceSeconds, (Config{ .seconds = std.math.inf(f64) }).validate()); - try std.testing.expectError(error.BadSilenceFloor, (Config{ .floor_dbfs = 1 }).validate()); -+ try (Config{ .floor_dbfs = -50, .seconds = 2, .onset_seconds = 1, .onset_voiced_seconds = 0.5 }).validate(); -+ for ([_]Config{ -+ .{ .floor_dbfs = -50, .seconds = 2, .onset_seconds = 2, .onset_voiced_seconds = 0.5 }, -+ .{ .onset_seconds = 1, .onset_voiced_seconds = 0.5 }, -+ .{ .floor_dbfs = -50, .seconds = 2, .onset_voiced_seconds = 0.5 }, -+ .{ .floor_dbfs = -50, .seconds = 2, .onset_seconds = 0, .onset_voiced_seconds = 0.5 }, -+ .{ .onset_seconds = 0, .onset_voiced_seconds = 0 }, -+ }) |c| try std.testing.expectError(error.BadSilenceOnset, c.validate()); -+ for ([_]Config{ -+ .{ .floor_dbfs = -50, .seconds = 2, .onset_seconds = 1 }, -+ .{ .floor_dbfs = -50, .seconds = 2, .onset_seconds = 1, .onset_voiced_seconds = 0 }, -+ }) |c| try std.testing.expectError(error.BadSilenceOnsetVoiced, c.validate()); - } - - test "silence flags are refused on text even when explicitly disabled" { -- for ([_]Config{ .{ .floor_dbfs = -80 }, .{ .seconds = 0 }, .{ .seconds = 1 } }) |config| { -+ for ([_]Config{ .{ .floor_dbfs = -80 }, .{ .seconds = 0 }, .{ .seconds = 1 }, .{ .onset_seconds = 0 }, .{ .onset_voiced_seconds = 0 } }) |config| { - try std.testing.expect(config.refusal("nemotron_h") != null); - try std.testing.expect(config.refusal("fish_omni") == null); - } - try std.testing.expect((Config{}).refusal("nemotron_h") == null); - } -+ -+test "the startup sentence adds the onset rule only when it is set" { -+ var buf: [128]u8 = undefined; -+ const tail: Config = .{ .floor_dbfs = -60, .seconds = 8 }; -+ const off = "drama3: silence guard on: a reply ends after 8 s below -60 dBFS"; -+ try std.testing.expectEqualStrings(off, try std.fmt.bufPrint(&buf, "drama3: silence guard on: {f}", .{tail})); -+ var onset = tail; -+ onset.onset_seconds = 2; -+ onset.onset_voiced_seconds = 0.25; -+ const on = off ++ ", or after 2 s below it with under 0.25 s voiced"; -+ try std.testing.expectEqualStrings(on, try std.fmt.bufPrint(&buf, "drama3: silence guard on: {f}", .{onset})); -+} -diff --git a/zig/src/native/drama_host.zig b/zig/src/native/drama_host.zig -index 2125bd64..ee6ce2b6 100644 ---- a/zig/src/native/drama_host.zig -+++ b/zig/src/native/drama_host.zig -@@ -313,52 +313,75 @@ pub fn open(gpa: std.mem.Allocator, io: std.Io, dir: []const u8, _: []const u8, - .speech = .{ .sample_rate = 44100, .frame_length = 2048, .max_frames = frame_cap, .max_prompt_tokens = budget.prompt }, - }; - h.worker = try std.Thread.spawn(.{}, Host.run, .{h}); -- if (o.silence.enabled()) -- std.log.info("drama3: silence guard on: a reply ends after {d} s below {d} dBFS", .{ o.silence.seconds.?, o.silence.floor_dbfs.? }); -+ if (o.silence.enabled()) std.log.info("drama3: silence guard on: {f}", .{o.silence}); - return .{ .engine = .{ .ctx = h, .vtable = &Host.vtable }, .ctx = h, .close = Host.close }; - } - -+/// Two-sample frames at 8 Hz, decoded through the host's PCM callback and the shared decode schedule. -+const FakeCodec = struct { -+ pcm: []const f32, -+ calls: usize = 0, -+ -+ pub fn flush(t: *FakeCodec, from: usize, to: usize, sink: d3.tts.Sink) !void { -+ t.calls += 1; -+ try sink.audio(sink.ctx, t.pcm[from * 2 .. to * 2]); -+ } -+ -+ fn event(_: *anyopaque, _: api.Id, _: *const api.Event) void {} -+ -+ /// Generates every frame until the guard trips; the samples kept into `out`, and whether it tripped. -+ fn run(t: *FakeCodec, silence: api.speech_guard.Config, stream: bool, out: []f32) !struct { usize, bool } { -+ var result: api.SpeechResult = .{}; -+ const r: api.SpeechRequest = .{ .prompt = &.{}, .pcm = out, .result = &result, .seed = 0, .stream = stream }; -+ var j: Job = .{ .id = 0, .request = &r, .sink = .{ .ctx = t, .event = event }, .guard = .init(silence, 8), .frame_length = 2 }; -+ const sink: d3.tts.Sink = .{ .ctx = &j, .audio = Host.copyAudio }; -+ const frames = t.pcm.len / 2; -+ for (0..frames) |i| { -+ try decode.flush(t, j.incremental(), i, i + 1, false, sink); -+ if (j.guard.tripped) return .{ j.samples, true }; -+ } -+ try decode.flush(t, j.incremental(), frames, frames, true, sink); -+ return .{ j.samples, false }; -+ } -+}; -+ - test "native silence guard: whole and stream share decode cuts; off preserves PCM" { -- const Fake = struct { -- calls: usize = 0, -- const pcm = [_]f32{ 0.25, -0.25, 0, 0, 0, 0, 0.25, -0.25 }; -- pub fn flush(t: *@This(), from: usize, to: usize, sink: d3.tts.Sink) !void { -- t.calls += 1; -- try sink.audio(sink.ctx, pcm[from * 2 .. to * 2]); -+ const pcm = [_]f32{ 0.25, -0.25, 0, 0, 0, 0, 0.25, -0.25 }; -+ for ([_]bool{ false, true }) |enabled| { -+ for ([_]bool{ false, true }) |stream| { -+ var codec: FakeCodec = .{ .pcm = &pcm }; -+ var out: [pcm.len]f32 = undefined; -+ const samples, const tripped = try codec.run(if (enabled) .{ .floor_dbfs = -20, .seconds = 0.5 } else .{}, stream, &out); -+ const kept: usize = if (enabled) 6 else pcm.len; -+ try std.testing.expectEqual(kept, samples); -+ try std.testing.expectEqual(enabled, tripped); -+ try std.testing.expectEqualSlices(f32, pcm[0..kept], out[0..samples]); -+ if (!enabled and !stream) try std.testing.expectEqual(@as(usize, 1), codec.calls); - } -- fn event(_: *anyopaque, _: api.Id, _: *const api.Event) void {} -+ } -+} -+ -+test "native silence guard: onset silence cuts whole and stream at one frame; speech past the minimum is kept" { -+ const word = [_]f32{ 0.25, -0.25 }; -+ const hush = [_]f32{ 0, 0 }; -+ const one_word = word ++ hush ++ hush ++ hush ++ hush ++ word; -+ const late = hush ++ word ++ word ++ hush ++ hush ++ hush ++ word; -+ const tail: api.speech_guard.Config = .{ .floor_dbfs = -20, .seconds = 1 }; -+ const onset: api.speech_guard.Config = .{ .floor_dbfs = -20, .seconds = 1, .onset_seconds = 0.5, .onset_voiced_seconds = 0.5 }; -+ const cases = [_]struct { []const f32, api.speech_guard.Config, usize }{ -+ .{ &one_word, onset, 6 }, // one voiced frame, then two silent ones -+ .{ &one_word, tail, 10 }, // onset off: four silent frames -+ .{ &late, onset, late.len }, // a short silent start, then two voiced frames before the pause - }; -- for ([_]bool{ false, true }) |enabled| { -+ for (cases) |case| { -+ const pcm, const silence, const kept = case; - for ([_]bool{ false, true }) |stream| { -- var pcm: [Fake.pcm.len]f32 = undefined; -- var result: api.SpeechResult = .{}; -- const r: api.SpeechRequest = .{ -- .prompt = &.{}, -- .pcm = &pcm, -- .result = &result, -- .seed = 0, -- .stream = stream, -- }; -- var t: Fake = .{}; -- var j: Job = .{ -- .id = 0, -- .request = &r, -- .sink = .{ .ctx = &t, .event = Fake.event }, -- .guard = .init(if (enabled) .{ .floor_dbfs = -20, .seconds = 0.5 } else .{}, 8), -- .frame_length = 2, -- }; -- const sink: d3.tts.Sink = .{ .ctx = &j, .audio = Host.copyAudio }; -- for (0..Fake.pcm.len / 2) |i| { -- try decode.flush(&t, j.incremental(), i, i + 1, false, sink); -- if (j.guard.tripped) break; -- } -- if (!j.guard.tripped) -- try decode.flush(&t, j.incremental(), Fake.pcm.len / 2, Fake.pcm.len / 2, true, sink); -- const kept: usize = if (enabled) 6 else Fake.pcm.len; -- try std.testing.expectEqual(kept, j.samples); -- try std.testing.expectEqual(enabled, j.guard.tripped); -- try std.testing.expectEqualSlices(f32, Fake.pcm[0..kept], pcm[0..j.samples]); -- if (!enabled and !stream) try std.testing.expectEqual(@as(usize, 1), t.calls); -+ var codec: FakeCodec = .{ .pcm = pcm }; -+ var out: [late.len]f32 = undefined; -+ const samples, const tripped = try codec.run(silence, stream, &out); -+ try std.testing.expectEqual(kept, samples); -+ try std.testing.expectEqual(kept < pcm.len, tripped); -+ try std.testing.expectEqualSlices(f32, pcm[0..kept], out[0..samples]); - } - } - } -diff --git a/zig/src/server/cli.zig b/zig/src/server/cli.zig -index 308eebf2..ac310216 100644 ---- a/zig/src/server/cli.zig -+++ b/zig/src/server/cli.zig -@@ -79,6 +79,8 @@ pub const flags = [_]Flag{ - .{ .name = "--keep-warm", .native = true }, - .{ .name = "--silence-floor-dbfs", .native = true }, - .{ .name = "--silence-seconds", .native = true }, -+ .{ .name = "--silence-onset-s", .native = true }, -+ .{ .name = "--silence-onset-voiced-s", .native = true }, - .{ .name = "--compact-at", .native = true }, - .{ .name = "--compact-keep", .native = true }, - .{ .name = "--compact-memory", .native = true }, -@@ -342,7 +344,7 @@ fn apply(a: Allocator, out: *Args, name: []const u8, value: ?[]const u8, u: *Usa - out.max_frames = @intCast(n); - return; - } -- if (is(name, "--host")) out.host = v else if (is(name, "--port")) { -+ if (is(name, "--silence-onset-s")) out.silence.onset_seconds = try float(u, a, name, v) else if (is(name, "--silence-onset-voiced-s")) out.silence.onset_voiced_seconds = try float(u, a, name, v) else if (is(name, "--host")) out.host = v else if (is(name, "--port")) { - const p = try int(u, a, name, v); - if (p < 0 or p > 65535) return fail(u, a, "argument --port: invalid port: '{s}'", .{v}); - out.port = @intCast(p); -@@ -619,4 +621,8 @@ test "silence guard flags are opt-in and reach native open options" { - try std.testing.expect(opened.silence.enabled()); - try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-seconds", "1" }, &usage)); - try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-seconds", "nan" }, &usage)); -+ const onset = try parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-seconds", "2", "--silence-onset-s", "1", "--silence-onset-voiced-s", "0.5" }, &usage); -+ try std.testing.expectEqual(api.speech_guard.Config{ .floor_dbfs = -80, .seconds = 2, .onset_seconds = 1, .onset_voiced_seconds = 0.5 }, (try request(a, "m", "drama3", onset)).silence); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-seconds", "2", "--silence-onset-s", "1" }, &usage)); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-onset-s=0" }, &usage)); - } -diff --git a/zig/src/server/main.zig b/zig/src/server/main.zig -index f80b798f..075caaa5 100644 ---- a/zig/src/server/main.zig -+++ b/zig/src/server/main.zig -@@ -26,6 +26,7 @@ const usage_line = "usage: tensorfold serve [-h] [--host HOST] [--port PORT] [-- - " [--thinking-budget THINKING_BUDGET] [--loop-guard] [--no-drafts] [--drafter DRAFTER]" ++ - " [--drafter-bits {0,4}] [--record RECORD] [--keep-warm SECONDS]" ++ - " [--silence-floor-dbfs DBFS] [--silence-seconds SECONDS]" ++ -+ " [--silence-onset-s SECONDS] [--silence-onset-voiced-s SECONDS]" ++ - " [--compact-at COMPACT_AT] [--compact-keep COMPACT_KEEP]" ++ - " [--compact-memory COMPACT_MEMORY] [--slide] [--slide-graph SLIDE_GRAPH] [--parallel PARALLEL]" ++ - " [--cortheon] [--no-update-check] [--backend {auto,mlx,cuda}] [--device DEVICE]" ++ diff --git a/engine/patches/0008-feat-speech-add-opt-in-silence-mid-s-lost-ending-tri.patch b/engine/patches/0008-feat-speech-add-opt-in-silence-mid-s-lost-ending-tri.patch deleted file mode 100644 index 3c80be9..0000000 --- a/engine/patches/0008-feat-speech-add-opt-in-silence-mid-s-lost-ending-tri.patch +++ /dev/null @@ -1,544 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: plotarmordev <299844489+plotarmordev@users.noreply.github.com> -Date: Wed, 21 Oct 2026 08:05:42 +0800 -Subject: [PATCH 08/11] feat(speech): add opt-in --silence-mid-s lost-ending - trip - -K4: net diff of pr/silence-mid 245d9ad2 over K2 and K3. ---- - CHANGELOG.md | 2 + - README.md | 2 + - tools/zig/test_speech_flags.py | 6 +- - zig/src/core/speech_api.zig | 4 +- - zig/src/core/speech_guard.zig | 104 +++++++++++++++++++++++--- - zig/src/native/drama_host.zig | 70 ++++++++++++++++- - zig/src/server/cli.zig | 7 ++ - zig/src/server/cli_test.zig | 14 ++++ - zig/src/server/main.zig | 1 + - zig/src/server/serve.zig | 1 + - zig/src/server/server.zig | 1 + - zig/src/server/speech.zig | 8 +- - zig/src/server/speech_stream_test.zig | 36 ++++++++- - 13 files changed, 236 insertions(+), 20 deletions(-) - -diff --git a/CHANGELOG.md b/CHANGELOG.md -index af7b090a..07210cd2 100644 ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -12,6 +12,8 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - - - Native Drama speech can opt into `--silence-floor-dbfs` and `--silence-seconds` to keep audio through a sustained silent tail and finish as `silence`, including an HTTP finish header or stream trailer. The default is off. - -+- Native Drama speech can opt into `--silence-mid-s` (below `--silence-seconds`) with an explicit `--silence-mid-min-voiced-s` to finish as `silence` when end of speech falls in a long silent run after enough voiced audio. Configured whole replies carry the decimal `X-Silence-Mid-Trips` count, including zero; streams and errors omit it. The default is off. -+ - ## 1.1.1 - - - The app checks models through HTTPS Hub fake-IP proxies in both address families and honours one validated `HF_ENDPOINT` for checks and downloads. Network-policy refusals name the DNS address and proxy remedy (#1037). Thanks to @samwang0041-star. -diff --git a/README.md b/README.md -index 1652df0b..de8efbb6 100644 ---- a/README.md -+++ b/README.md -@@ -247,6 +247,8 @@ The binary's `capabilities --json` response lists its supported flags and platfo - | `--silence-seconds SECONDS` | Drama speech only: stop after this continuous duration below the explicit floor, retaining audio and reporting `silence`. Unset by default: off; calibrate above intentional pauses. | - | `--silence-onset-s SECONDS` | Drama speech only: with the silence guard on, stop once this many seconds run below its floor while the synthesis has voiced less than `--silence-onset-voiced-s`, retaining audio and reporting `silence`. Off unless given; must be below `--silence-seconds`. | - | `--silence-onset-voiced-s SECONDS` | Drama speech only: the voiced seconds below which `--silence-onset-s` applies; required with it, with no default. Calibrate on public or built-in speech. | -+| `--silence-mid-s SECONDS` | Drama speech only: with the silence guard on, finish as `silence` when end of speech falls in a run this long below the floor, after the explicit voiced minimum. Whole replies carry the decimal `X-Silence-Mid-Trips` count, including zero. Must be below `--silence-seconds` and paired with `--silence-mid-min-voiced-s`. Unset by default: off. | -+| `--silence-mid-min-voiced-s SECONDS` | Required with `--silence-mid-s`: positive finite cumulative voiced duration before the mid rule applies, including equality. No default; calibrate with the other silence thresholds. | - | `--parallel N` | Admit up to N requests where the engine shares lanes; `auto` is the default. | - | `--prompt-cache-gib GIB` | Retained-prefix budget, 16 GiB by default or less where memory is short; 0 disables it. | - | `--prompt-cache-over-cap` | Permit an explicit prefix budget past what 70% of RAM leaves once the server loads. | -diff --git a/tools/zig/test_speech_flags.py b/tools/zig/test_speech_flags.py -index 591e540d..f3abbdb8 100644 ---- a/tools/zig/test_speech_flags.py -+++ b/tools/zig/test_speech_flags.py -@@ -7,7 +7,7 @@ import unittest - - ROOT = Path(__file__).resolve().parents[2] - BINARY = ROOT / "zig-out/native/bin/tensorfold-native" --MESSAGE = "--silence-floor-dbfs, --silence-seconds, --silence-onset-s and --silence-onset-voiced-s are for Drama speech: serve without them" -+MESSAGE = "--silence-floor-dbfs, --silence-seconds, the silence-onset flags and the silence-mid flags are for Drama speech: serve without them" - - - @unittest.skipUnless(BINARY.is_file(), "build native before running the CLI admission test") -@@ -18,7 +18,9 @@ class SpeechFlags(unittest.TestCase): - for flags in (("--silence-floor-dbfs=-80",), ("--silence-seconds=0",), - ("--silence-floor-dbfs=-80", "--silence-seconds=1"), - ("--silence-floor-dbfs=-80", "--silence-seconds=2", "--silence-onset-s=1", -- "--silence-onset-voiced-s=0.5")): -+ "--silence-onset-voiced-s=0.5"), -+ ("--silence-floor-dbfs=-80", "--silence-seconds=1", "--silence-mid-s=0.5", -+ "--silence-mid-min-voiced-s=1")): - with self.subTest(flags=flags): - result = subprocess.run( - [str(BINARY), "serve", folder, *flags], -diff --git a/zig/src/core/speech_api.zig b/zig/src/core/speech_api.zig -index a6bc2b61..4839cfee 100644 ---- a/zig/src/core/speech_api.zig -+++ b/zig/src/core/speech_api.zig -@@ -1,5 +1,5 @@ --/// Mono PCM written before a successful speech request's finished event. --pub const SpeechResult = struct { samples: usize = 0, reason: @import("engine_api.zig").Reason = .stop }; -+/// Mono PCM written before a successful speech request's finished event; `silence_mid`: speech ended in a long silence. -+pub const SpeechResult = struct { samples: usize = 0, reason: @import("engine_api.zig").Reason = .stop, silence_mid: bool = false }; - - /// Caller-owned until finished, even on cancellation; a streamed request's pcm[0..n] is the caller's after ``audio`` n. - pub const SpeechRequest = struct { -diff --git a/zig/src/core/speech_guard.zig b/zig/src/core/speech_guard.zig -index 424a5bc6..5868ca83 100644 ---- a/zig/src/core/speech_guard.zig -+++ b/zig/src/core/speech_guard.zig -@@ -6,6 +6,8 @@ pub const Config = struct { - seconds: ?f64 = null, - onset_seconds: ?f64 = null, - onset_voiced_seconds: ?f64 = null, -+ mid_seconds: ?f64 = null, -+ mid_min_voiced_s: ?f64 = null, - - pub fn validate(c: Config) !void { - if (c.seconds) |seconds| { -@@ -15,6 +17,9 @@ pub const Config = struct { - if (!std.math.isFinite(floor) or floor > 0 or std.math.pow(f64, 10, floor / 10) == 0) - return error.BadSilenceFloor; - } else if ((c.seconds orelse 0) > 0) return error.MissingSilenceFloor; -+ if (c.mid_seconds) |mid| if (!(mid > 0 and mid < (c.seconds orelse 0))) return error.BadSilenceMidSeconds; -+ if ((c.mid_seconds != null) != (c.mid_min_voiced_s != null)) return error.MissingSilenceMidVoiced; -+ if (c.mid_min_voiced_s) |s| if (!std.math.isFinite(s) or s <= 0) return error.BadSilenceMidVoiced; - if (c.onset_seconds == null and c.onset_voiced_seconds == null) return; - const onset = c.onset_seconds orelse return error.BadSilenceOnset; - const voiced = c.onset_voiced_seconds orelse return error.BadSilenceOnsetVoiced; -@@ -28,15 +33,16 @@ pub const Config = struct { - - pub fn refusal(c: Config, model_type: []const u8) ?[]const u8 { - if (!std.mem.eql(u8, model_type, "fish_omni") and -- (c.floor_dbfs != null or c.seconds != null or c.onset_seconds != null or c.onset_voiced_seconds != null)) -- return "--silence-floor-dbfs, --silence-seconds, --silence-onset-s and --silence-onset-voiced-s are for Drama speech: serve without them"; -+ (c.floor_dbfs != null or c.seconds != null or c.onset_seconds != null or c.onset_voiced_seconds != null or c.mid_seconds != null or c.mid_min_voiced_s != null)) -+ return "--silence-floor-dbfs, --silence-seconds, the silence-onset flags and the silence-mid flags are for Drama speech: serve without them"; - return null; - } - -- /// The startup sentence's rules for an enabled guard: the silent tail, then the onset rule when set. -+ /// Keep optional clauses ordered so combined configurations remain one sentence. - pub fn format(c: Config, w: *std.Io.Writer) std.Io.Writer.Error!void { - try w.print("a reply ends after {d} s below {d} dBFS", .{ c.seconds.?, c.floor_dbfs.? }); - if (c.onset_seconds) |onset| try w.print(", or after {d} s below it with under {d} s voiced", .{ onset, c.onset_voiced_seconds.? }); -+ if (c.mid_seconds) |mid| try w.print(", or as silence when it stops after {d} s below it with at least {d} s voiced", .{ mid, c.mid_min_voiced_s.? }); - } - }; - -@@ -46,9 +52,12 @@ pub const Guard = struct { - /// Silent samples that end a synthesis voiced for fewer than `onset_voiced` samples. - onset_limit: f64 = 0, - onset_voiced: f64 = 0, -+ mid: f64 = 0, -+ mid_min_voiced: f64 = 0, - silent_samples: usize = 0, - voiced_samples: usize = 0, - tripped: bool = false, -+ mid_tripped: bool = false, - - pub fn init(c: Config, sample_rate: u32) Guard { - if (!c.enabled()) return .{}; -@@ -58,6 +67,8 @@ pub const Guard = struct { - .limit = c.seconds.? * rate, - .onset_limit = (c.onset_seconds orelse 0) * rate, - .onset_voiced = (c.onset_voiced_seconds orelse 0) * rate, -+ .mid = (c.mid_seconds orelse 0) * rate, -+ .mid_min_voiced = (c.mid_min_voiced_s orelse 0) * rate, - }; - } - -@@ -88,6 +99,14 @@ pub const Guard = struct { - } - return end; - } -+ -+ /// At end of speech: the mid rule needs both its silent run and the configured amount of prior voiced audio. -+ pub fn endSpeech(g: *Guard) void { -+ if (g.mid == 0 or g.tripped or @as(f64, @floatFromInt(g.silent_samples)) < g.mid) return; -+ if (@as(f64, @floatFromInt(g.voiced_samples)) < g.mid_min_voiced) return; -+ g.tripped = true; -+ g.mid_tripped = true; -+ } - }; - - test "silence tail cuts at a frame boundary and latches before later speech" { -@@ -113,6 +132,8 @@ test "quiet voiced frames reset silence and disabled guard preserves all samples - test "silence configuration requires explicit finite thresholds" { - try (Config{}).validate(); - try std.testing.expectError(error.MissingSilenceFloor, (Config{ .seconds = 1 }).validate()); -+ for ([_]Config{ .{ .floor_dbfs = -20, .mid_seconds = 0.5 }, .{ .floor_dbfs = -20, .seconds = 1, .mid_seconds = 0 }, .{ .floor_dbfs = -20, .seconds = 1, .mid_seconds = 1 }, .{ .floor_dbfs = -20, .seconds = 1, .mid_seconds = std.math.nan(f64) } }) |c| -+ try std.testing.expectError(error.BadSilenceMidSeconds, c.validate()); - try std.testing.expectError(error.BadSilenceSeconds, (Config{ .seconds = std.math.inf(f64) }).validate()); - try std.testing.expectError(error.BadSilenceFloor, (Config{ .floor_dbfs = 1 }).validate()); - try (Config{ .floor_dbfs = -50, .seconds = 2, .onset_seconds = 1, .onset_voiced_seconds = 0.5 }).validate(); -@@ -129,22 +150,85 @@ test "silence configuration requires explicit finite thresholds" { - }) |c| try std.testing.expectError(error.BadSilenceOnsetVoiced, c.validate()); - } - -+test "end of speech in a mid silence trips; after shorter pauses, or with the rule off, it does not" { -+ const hole = [_]f32{ 0.25, -0.25, 0, 0, 0, 0 }; -+ const pauses = [_]f32{ 0.25, -0.25, 0, 0, 0.25, -0.25, 0, 0 }; -+ var guard = Guard.init(.{ .floor_dbfs = -20, .seconds = 1, .mid_seconds = 0.5, .mid_min_voiced_s = 0.25 }, 8); -+ try std.testing.expectEqual(hole.len, guard.hear(&hole, 2)); -+ try std.testing.expect(!guard.tripped); -+ guard.endSpeech(); -+ try std.testing.expect(guard.tripped and guard.mid_tripped); -+ var paused = Guard.init(.{ .floor_dbfs = -20, .seconds = 1, .mid_seconds = 0.5, .mid_min_voiced_s = 0.25 }, 8); -+ try std.testing.expectEqual(pauses.len, paused.hear(&pauses, 2)); -+ paused.endSpeech(); -+ try std.testing.expect(!paused.tripped); -+ var off = Guard.init(.{ .floor_dbfs = -20, .seconds = 1 }, 8); -+ try std.testing.expectEqual(hole.len, off.hear(&hole, 2)); -+ off.endSpeech(); -+ try std.testing.expect(!off.tripped); -+} -+ -+test "a tail trip before end of speech is not a mid trip" { -+ var guard = Guard.init(.{ .floor_dbfs = -20, .seconds = 0.75, .mid_seconds = 0.5, .mid_min_voiced_s = 0.25 }, 8); -+ const pcm = [_]f32{ 0.25, -0.25, 0, 0, 0, 0, 0, 0 }; -+ try std.testing.expectEqual(@as(usize, 8), guard.hear(&pcm, 2)); -+ guard.endSpeech(); -+ try std.testing.expect(guard.tripped and !guard.mid_tripped); -+} -+ - test "silence flags are refused on text even when explicitly disabled" { -- for ([_]Config{ .{ .floor_dbfs = -80 }, .{ .seconds = 0 }, .{ .seconds = 1 }, .{ .onset_seconds = 0 }, .{ .onset_voiced_seconds = 0 } }) |config| { -+ for ([_]Config{ .{ .floor_dbfs = -80 }, .{ .seconds = 0 }, .{ .seconds = 1 }, .{ .onset_seconds = 0 }, .{ .onset_voiced_seconds = 0 }, .{ .mid_seconds = 0 }, .{ .mid_seconds = 1 } }) |config| { - try std.testing.expect(config.refusal("nemotron_h") != null); - try std.testing.expect(config.refusal("fish_omni") == null); - } - try std.testing.expect((Config{}).refusal("nemotron_h") == null); - } - --test "the startup sentence adds the onset rule only when it is set" { -- var buf: [128]u8 = undefined; -+test "the startup sentence preserves tail onset mid and combined forms" { -+ var buf: [256]u8 = undefined; - const tail: Config = .{ .floor_dbfs = -60, .seconds = 8 }; - const off = "drama3: silence guard on: a reply ends after 8 s below -60 dBFS"; - try std.testing.expectEqualStrings(off, try std.fmt.bufPrint(&buf, "drama3: silence guard on: {f}", .{tail})); - var onset = tail; -- onset.onset_seconds = 2; -- onset.onset_voiced_seconds = 0.25; -- const on = off ++ ", or after 2 s below it with under 0.25 s voiced"; -- try std.testing.expectEqualStrings(on, try std.fmt.bufPrint(&buf, "drama3: silence guard on: {f}", .{onset})); -+ onset.onset_seconds = 5; -+ onset.onset_voiced_seconds = 2; -+ const onset_clause = ", or after 5 s below it with under 2 s voiced"; -+ try std.testing.expectEqualStrings(off ++ onset_clause, try std.fmt.bufPrint(&buf, "drama3: silence guard on: {f}", .{onset})); -+ var mid = tail; -+ mid.mid_seconds = 6; -+ mid.mid_min_voiced_s = 2; -+ const mid_clause = ", or as silence when it stops after 6 s below it with at least 2 s voiced"; -+ try std.testing.expectEqualStrings(off ++ mid_clause, try std.fmt.bufPrint(&buf, "drama3: silence guard on: {f}", .{mid})); -+ var both = onset; -+ both.mid_seconds = mid.mid_seconds; -+ both.mid_min_voiced_s = mid.mid_min_voiced_s; -+ try std.testing.expectEqualStrings(off ++ onset_clause ++ mid_clause, try std.fmt.bufPrint(&buf, "drama3: silence guard on: {f}", .{both})); -+} -+ -+test "mid silence needs the explicit voiced minimum, inclusive and cumulative across phrases" { -+ const voiced = [_]f32{ 0.25, -0.25 }; -+ const silence = [_]f32{ 0, 0, 0, 0 }; -+ for (0..6) |frames| { -+ var g = Guard.init(.{ .floor_dbfs = -20, .seconds = 1, .mid_seconds = 0.5, .mid_min_voiced_s = 1 }, 8); -+ for (0..frames) |_| { -+ _ = g.hear(&voiced, 2); -+ _ = g.hear(silence[0..2], 2); -+ } -+ _ = g.hear(&silence, 2); -+ g.endSpeech(); -+ try std.testing.expectEqual(frames >= 4, g.mid_tripped); -+ } -+} -+ -+test "mid voiced calibration has no default and must be positive and finite" { -+ var c: Config = .{ .floor_dbfs = -20, .seconds = 1, .mid_seconds = 0.5 }; -+ try std.testing.expectError(error.MissingSilenceMidVoiced, c.validate()); -+ for ([_]f64{ 0, -1, std.math.inf(f64), std.math.nan(f64) }) |bad| { -+ c.mid_min_voiced_s = bad; -+ try std.testing.expectError(error.BadSilenceMidVoiced, c.validate()); -+ } -+ c.mid_min_voiced_s = 1; -+ try c.validate(); -+ c.mid_seconds = null; -+ try std.testing.expectError(error.MissingSilenceMidVoiced, c.validate()); - } -diff --git a/zig/src/native/drama_host.zig b/zig/src/native/drama_host.zig -index ee6ce2b6..d8b05a7e 100644 ---- a/zig/src/native/drama_host.zig -+++ b/zig/src/native/drama_host.zig -@@ -192,13 +192,20 @@ const Host = struct { - try s.e.frameStep(smp, true, null); - var frame: d3.model.Frame = undefined; - try s.e.download(s.e.frames_out + j.state.frames * 11 * 4, std.mem.asBytes(&frame)); -- if (frame[0] == s.e.c.ids.eos) { -- try decode.flush(s.t, j.incremental(), j.state.frames, j.state.frames, true, @as(d3.tts.Sink, .{ .ctx = j, .audio = copyAudio })); -+ try h.produced(i, s.t, frame[0] == s.e.c.ids.eos); -+ } -+ /// After a frame step: decodes its frame, or at end of speech the rest, and finishes the job if it ended. -+ fn produced(h: *Host, i: usize, t: anytype, eos: bool) !void { -+ const j = &h.jobs[i].?; -+ const r = j.request; -+ if (eos) { -+ try decode.flush(t, j.incremental(), j.state.frames, j.state.frames, true, @as(d3.tts.Sink, .{ .ctx = j, .audio = copyAudio })); -+ j.guard.endSpeech(); - return h.finish(i, if (j.guard.tripped) .silence else .stop, ""); - } - const from = j.state.frames; - j.state.frames += 1; -- try decode.flush(s.t, j.incremental(), from, j.state.frames, j.state.frames == r.max_frames, @as(d3.tts.Sink, .{ .ctx = j, .audio = copyAudio })); -+ try decode.flush(t, j.incremental(), from, j.state.frames, j.state.frames == r.max_frames, @as(d3.tts.Sink, .{ .ctx = j, .audio = copyAudio })); - if (r.stream) j.sink.event(j.sink.ctx, j.id, &.{ .audio = j.samples }); - if (j.guard.tripped) return h.finish(i, .silence, ""); - if (j.state.frames == r.max_frames) h.finish(i, .length, ""); -@@ -222,6 +229,7 @@ const Host = struct { - if (final_reason == .stop or final_reason == .length or final_reason == .silence) - j.request.result.samples = j.samples; - j.request.result.reason = final_reason; -+ j.request.result.silence_mid = final_reason == .silence and j.guard.mid_tripped; - h.jobs[lane] = null; - h.queue.release(lane); - h.unlock(); -@@ -385,3 +393,59 @@ test "native silence guard: onset silence cuts whole and stream at one frame; sp - } - } - } -+ -+test "native silence guard: a finished job publishes its reason and mid trip alike in whole and stream" { -+ if (!is_cuda) return error.SkipZigTest; -+ const Fake = struct { -+ pcm: []const f32, -+ var drained = true; -+ var finished: ?api.Reason = null; -+ pub fn flush(t: *@This(), from: usize, to: usize, sink: d3.tts.Sink) !void { -+ try sink.audio(sink.ctx, t.pcm[from * 2 .. to * 2]); -+ } -+ fn event(_: *anyopaque, _: api.Id, e: *const api.Event) void { -+ if (e.* == .finished) finished = e.finished.reason; -+ } -+ fn wait(_: cuda.abi.Stream) callconv(.c) cuda.abi.Result { -+ return if (drained) cuda.abi.success else cuda.abi.error_not_ready; -+ } -+ }; -+ var e: d3.Engine = undefined; -+ e.driver.api.cuStreamSynchronize = Fake.wait; -+ e.stream = .borrowed(&e.driver, null); -+ e.stream2 = .borrowed(&e.driver, null); -+ var tts: d3.tts.Tts = undefined; -+ tts.e = &e; -+ tts.cs = .borrowed(&e.driver, null); -+ const hole = [_]f32{ 0.25, -0.25, 0, 0, 0, 0 }; -+ const pauses = [_]f32{ 0.25, -0.25, 0, 0, 0.25, -0.25, 0, 0 }; -+ const mid: api.speech_guard.Config = .{ .floor_dbfs = -20, .seconds = 1, .mid_seconds = 0.5, .mid_min_voiced_s = 0.25 }; -+ const Case = struct { pcm: []const f32, silence: api.speech_guard.Config, max_frames: u32 = 16, drained: bool = true, reason: api.Reason, mid: bool = false }; -+ const cases = [_]Case{ -+ .{ .pcm = &hole, .silence = mid, .reason = .silence, .mid = true }, -+ .{ .pcm = &pauses, .silence = mid, .reason = .stop }, -+ .{ .pcm = &hole, .silence = .{}, .reason = .stop }, -+ .{ .pcm = &hole, .silence = .{ .floor_dbfs = -20, .seconds = 0.5 }, .reason = .silence }, -+ .{ .pcm = &hole, .silence = mid, .max_frames = 3, .reason = .length }, -+ .{ .pcm = &hole, .silence = mid, .drained = false, .reason = .failed }, -+ }; -+ for (cases) |case| { -+ for ([_]bool{ false, true }) |stream| { -+ Fake.drained = case.drained; -+ Fake.finished = null; -+ var pcm: [pauses.len]f32 = undefined; -+ var result: api.SpeechResult = .{}; -+ const r: api.SpeechRequest = .{ .prompt = &.{}, .pcm = &pcm, .result = &result, .seed = 0, .max_frames = case.max_frames, .stream = stream }; -+ var t: Fake = .{ .pcm = case.pcm }; -+ var h: Host = .{ .gpa = std.testing.allocator, .io = std.testing.io, .limit = undefined, .queue = try rounds.Queue.init(1), .info_ = .{}, .silence = case.silence }; -+ h.sessions[0] = .{ .e = &e, .t = &tts }; -+ const lane = try h.queue.admit(); -+ h.jobs[lane] = .{ .id = 0, .request = &r, .sink = .{ .ctx = &t, .event = Fake.event }, .guard = .init(case.silence, 8), .frame_length = 2 }; -+ for (0..case.pcm.len / 2) |_| if (h.jobs[lane] != null) try h.produced(lane, &t, false); -+ if (h.jobs[lane] != null) try h.produced(lane, &t, true); -+ try std.testing.expectEqual(case.reason, Fake.finished.?); -+ try std.testing.expectEqual(case.reason, result.reason); -+ try std.testing.expectEqual(case.mid, result.silence_mid); -+ } -+ } -+} -diff --git a/zig/src/server/cli.zig b/zig/src/server/cli.zig -index ac310216..bb8c97d4 100644 ---- a/zig/src/server/cli.zig -+++ b/zig/src/server/cli.zig -@@ -81,6 +81,8 @@ pub const flags = [_]Flag{ - .{ .name = "--silence-seconds", .native = true }, - .{ .name = "--silence-onset-s", .native = true }, - .{ .name = "--silence-onset-voiced-s", .native = true }, -+ .{ .name = "--silence-mid-s", .native = true }, -+ .{ .name = "--silence-mid-min-voiced-s", .native = true }, - .{ .name = "--compact-at", .native = true }, - .{ .name = "--compact-keep", .native = true }, - .{ .name = "--compact-memory", .native = true }, -@@ -314,6 +316,11 @@ fn apply(a: Allocator, out: *Args, name: []const u8, value: ?[]const u8, u: *Usa - out.silence.seconds = try float(u, a, name, v); - return; - } -+ if (is(name, "--silence-mid-s") or is(name, "--silence-mid-min-voiced-s")) { -+ const seconds = try float(u, a, name, v); -+ if (is(name, "--silence-mid-s")) out.silence.mid_seconds = seconds else out.silence.mid_min_voiced_s = seconds; -+ return; -+ } - if (is(name, "--allowed-host")) { - if (!@import("request_guard.zig").validHost(v)) return fail(u, a, "argument --allowed-host: expected a hostname or bracketed IP without a port: '{s}'", .{v}); - return repeated.hosts.append(a, v); -diff --git a/zig/src/server/cli_test.zig b/zig/src/server/cli_test.zig -index b7da0ff5..7b2c9a11 100644 ---- a/zig/src/server/cli_test.zig -+++ b/zig/src/server/cli_test.zig -@@ -172,3 +172,17 @@ test "runtime library: capabilities serializes found and missing dependencies wi - try std.testing.expectEqualStrings("libnccl.so.2", values[0].object.get("soname").?.string); - try std.testing.expectEqualStrings(rows[1].install, values[1].object.get("install").?.string); - } -+ -+test "--silence-mid-s is opt-in, needs the tail guard and reaches native open options" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ var u: Usage = .{}; -+ try std.testing.expectEqual(@as(?f64, null), (try parse(a, &.{"m"}, &u)).silence.mid_seconds); -+ const args = try parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-seconds", "10", "--silence-mid-s", "3", "--silence-mid-min-voiced-s", "1" }, &u); -+ try std.testing.expectEqual(@as(?f64, 3), (try request(a, "m", "drama3", args)).silence.mid_seconds); -+ try std.testing.expectEqual(@as(?f64, 1), (try request(a, "m", "drama3", args)).silence.mid_min_voiced_s); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-seconds", "10", "--silence-mid-s", "3" }, &u)); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-mid-s", "3" }, &u)); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-seconds", "3", "--silence-mid-s", "3" }, &u)); -+} -diff --git a/zig/src/server/main.zig b/zig/src/server/main.zig -index 075caaa5..d5d26875 100644 ---- a/zig/src/server/main.zig -+++ b/zig/src/server/main.zig -@@ -27,6 +27,7 @@ const usage_line = "usage: tensorfold serve [-h] [--host HOST] [--port PORT] [-- - " [--drafter-bits {0,4}] [--record RECORD] [--keep-warm SECONDS]" ++ - " [--silence-floor-dbfs DBFS] [--silence-seconds SECONDS]" ++ - " [--silence-onset-s SECONDS] [--silence-onset-voiced-s SECONDS]" ++ -+ " [--silence-mid-s SECONDS] [--silence-mid-min-voiced-s SECONDS]" ++ - " [--compact-at COMPACT_AT] [--compact-keep COMPACT_KEEP]" ++ - " [--compact-memory COMPACT_MEMORY] [--slide] [--slide-graph SLIDE_GRAPH] [--parallel PARALLEL]" ++ - " [--cortheon] [--no-update-check] [--backend {auto,mlx,cuda}] [--device DEVICE]" ++ -diff --git a/zig/src/server/serve.zig b/zig/src/server/serve.zig -index 50ec90fc..4dadeb85 100644 ---- a/zig/src/server/serve.zig -+++ b/zig/src/server/serve.zig -@@ -163,6 +163,7 @@ pub fn run(gpa: Allocator, io: std.Io, args: cli.Args, s: Setup) u8 { - .use_drafts = !args.no_drafts, - .vision_urls = args.vision_urls, - .seed_salt = salt, -+ .silence_mid = args.silence.mid_seconds != null, - .prompt_seed = args.prompt_seed, - .sse_keepalive_s = keepalive_s, - .request_log = env(s, "TENSORFOLD_REQUEST_LOG"), -diff --git a/zig/src/server/server.zig b/zig/src/server/server.zig -index e7b00a8a..5f888c91 100644 ---- a/zig/src/server/server.zig -+++ b/zig/src/server/server.zig -@@ -56,6 +56,7 @@ pub const Config = struct { - /// --vision-urls: image parts may name public HTTPS URLs, fetched before admission (media_fetch.zig). - vision_urls: bool = false, - seed_salt: i64 = 0, -+ silence_mid: bool = false, - /// --prompt-seed keeps historical prompt-keyed sampling when a request omits its seed. - prompt_seed: bool = false, - /// TENSORFOLD_REQUEST_LOG: where chat and completion bodies are appended; null: nowhere. -diff --git a/zig/src/server/speech.zig b/zig/src/server/speech.zig -index 02bb85cf..3b604a8b 100644 ---- a/zig/src/server/speech.zig -+++ b/zig/src/server/speech.zig -@@ -183,7 +183,7 @@ fn synthesize(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInf - if (why(&box, samples, pcm.len)) |message| return fail(c, cx, id, .server, message); - const data = quantizeInPlace(pcm[0..samples]) catch return fail(c, cx, id, .server, "the synthesized audio has non-finite samples"); - if (data.len > std.math.maxInt(u32) - 36) return fail(c, cx, id, .server, "the synthesized audio is too long for one reply"); -- sendAudio(c, plan.format, sp.sample_rate, data, box.reason) catch { -+ sendAudio(c, plan.format, sp.sample_rate, data, box.reason, if (srv.config.silence_mid) @intFromBool(result.silence_mid) else null) catch { - c.close = true; // a head already started or a client gone: nothing more can be sent on this connection - return; - }; -@@ -329,7 +329,7 @@ fn send(c: *Conn, cx: *Cx, id: []const u8) void { - } - - /// The head, then the WAV header and the samples where they lie: the audio is never copied to prepend a header. --fn sendAudio(c: *Conn, format: Format, sample_rate: u32, data: []const u8, reason: api.Reason) error{ Closed, OutOfMemory }!void { -+fn sendAudio(c: *Conn, format: Format, sample_rate: u32, data: []const u8, reason: api.Reason, silence_mid: ?u8) error{ Closed, OutOfMemory }!void { - const header: ?[wav_header_len]u8 = if (format == .wav) wavHeader(sample_rate, @intCast(data.len)) else null; - const header_len: usize = if (header != null) wav_header_len else 0; - try c.startResponse(200, null); -@@ -337,6 +337,10 @@ fn sendAudio(c: *Conn, format: Format, sample_rate: u32, data: []const u8, reaso - var len: [24]u8 = undefined; - try c.addHeader("Content-Length", std.fmt.bufPrint(&len, "{d}", .{header_len + data.len}) catch unreachable); - try c.addHeader("X-Finish-Reason", @tagName(reason)); -+ if (silence_mid) |trips| { -+ var count: [3]u8 = undefined; -+ try c.addHeader("X-Silence-Mid-Trips", std.fmt.bufPrint(&count, "{d}", .{trips}) catch unreachable); -+ } - if (c.close) try c.addHeader("Connection", "close"); - defer c.out.clearRetainingCapacity(); - if (!std.mem.eql(u8, c.version, "HTTP/0.9")) try c.out.appendSlice(c.gpa, "\r\n"); -diff --git a/zig/src/server/speech_stream_test.zig b/zig/src/server/speech_stream_test.zig -index aee4fabe..16065048 100644 ---- a/zig/src/server/speech_stream_test.zig -+++ b/zig/src/server/speech_stream_test.zig -@@ -14,6 +14,8 @@ const gpa = std.testing.allocator; - const Script = struct { - steps: []const usize, - reason: api.Reason = .stop, -+ silence_mid: bool = false, -+ mid_enabled: bool = false, - message: []const u8 = "", - hold: usize = 0, - inline_: bool = false, -@@ -53,6 +55,7 @@ const Script = struct { - } - if (e.reason == .stop or e.reason == .length or e.reason == .silence) request.result.samples = done; - request.result.reason = e.reason; -+ request.result.silence_mid = e.silence_mid; - sink.event(sink.ctx, id, &.{ .finished = .{ .reason = e.reason, .message = e.message } }); - } - fn cancel(ctx: *anyopaque, _: api.Id) void { -@@ -124,7 +127,7 @@ const Served = struct { - var pair: [2]std.c.fd_t = undefined; - if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair) != 0) return error.SocketPair; - s.* = .{ .srv = undefined, .client = pair[0], .route_fd = pair[1], .route = undefined, .write_ms = write_ms }; -- s.srv = try server.Server.init(gpa, std.testing.io, script.engine(), s.text.text(), .{ .served_name = "m", .model_ids = &.{"m"} }, null); -+ s.srv = try server.Server.init(gpa, std.testing.io, script.engine(), s.text.text(), .{ .served_name = "m", .model_ids = &.{"m"}, .silence_mid = script.mid_enabled }, null); - s.srv.voices = script.voices; - if (script.output_full) s.srv.speech_inflight.store(s.srv.info.lanes, .release); - var head: [160]u8 = undefined; -@@ -472,3 +475,34 @@ test "silence keeps audio with a whole header or stream trailer" { - } else try std.testing.expectEqualStrings(&pcm(1), rest); - } - } -+ -+test "a whole reply whose ending fell in a long silence says so in X-Silence-Mid-Trips; a stream ends as silence" { -+ for ([_]bool{ false, true }) |mid| { -+ for ([_]bool{ false, true }) |stream| { -+ var script: Script = .{ .steps = &.{frame}, .reason = .silence, .silence_mid = mid, .mid_enabled = true, .inline_ = true }; -+ var s: Served = undefined; -+ const whole = "{\"model\":\"m\",\"input\":\"hi\",\"response_format\":\"pcm\"}"; -+ try s.start(&script, "HTTP/1.1", if (stream) stream_body else whole, 2000); -+ defer s.deinit(); -+ const head = try s.readHead(); -+ if (!stream) { -+ try expectHeader(head, if (mid) "X-Silence-Mid-Trips: 1\r\n" else "X-Silence-Mid-Trips: 0\r\n"); -+ } else try std.testing.expect(std.mem.indexOf(u8, head, "X-Silence-Mid-Trips") == null); -+ const rest = try s.readRest(); -+ s.join(&script); -+ try std.testing.expect(std.mem.endsWith(u8, rest, if (stream) "\r\n0\r\nX-Finish-Reason: silence\r\n\r\n" else &pcm(1))); -+ } -+ } -+} -+ -+test "the mid header is absent with the guard off even if the engine reports a trip" { -+ var script: Script = .{ .steps = &.{frame}, .reason = .silence, .silence_mid = true, .inline_ = true }; -+ var s: Served = undefined; -+ try s.start(&script, "HTTP/1.1", "{\"model\":\"m\",\"input\":\"hi\",\"response_format\":\"pcm\"}", 2000); -+ defer s.deinit(); -+ const head = try s.readHead(); -+ const absent = std.mem.indexOf(u8, head, "X-Silence-Mid-Trips") == null; -+ _ = try s.readRest(); -+ s.join(&script); -+ try std.testing.expect(absent); -+} diff --git a/engine/patches/0009-feat-speech-redraw-whole-replies-the-silence-guard-e.patch b/engine/patches/0009-feat-speech-redraw-whole-replies-the-silence-guard-e.patch deleted file mode 100644 index a61e69c..0000000 --- a/engine/patches/0009-feat-speech-redraw-whole-replies-the-silence-guard-e.patch +++ /dev/null @@ -1,1291 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: plotarmordev <299844489+plotarmordev@users.noreply.github.com> -Date: Wed, 21 Oct 2026 08:05:43 +0800 -Subject: [PATCH 09/11] feat(speech): redraw whole replies the silence guard - ended - -K5: net diff of pr/silence-redraws 2e6d4858 over K2-K4 and K7. ---- - CHANGELOG.md | 2 + - README.md | 3 + - docs/speech-redraws.md | 67 +++++++++ - tools/zig/test_speech_flags.py | 16 +- - zig/src/server/cli.zig | 30 ++-- - zig/src/server/cli_test.zig | 47 ++++++ - zig/src/server/main.zig | 4 +- - zig/src/server/reply_text.zig | 2 +- - zig/src/server/root.zig | 2 + - zig/src/server/serve.zig | 1 + - zig/src/server/server.zig | 1 + - zig/src/server/speech.zig | 202 ++++++++++++++++++++------ - zig/src/server/speech_plain.zig | 84 +++++++++++ - zig/src/server/speech_plain_test.zig | 26 ++++ - zig/src/server/speech_redraw.zig | 81 +++++++++++ - zig/src/server/speech_redraw_test.zig | 189 ++++++++++++++++++++++++ - zig/src/server/speech_stream_test.zig | 67 ++++++--- - 17 files changed, 728 insertions(+), 96 deletions(-) - create mode 100644 docs/speech-redraws.md - create mode 100644 zig/src/server/speech_plain.zig - create mode 100644 zig/src/server/speech_plain_test.zig - create mode 100644 zig/src/server/speech_redraw.zig - create mode 100644 zig/src/server/speech_redraw_test.zig - -diff --git a/CHANGELOG.md b/CHANGELOG.md -index 07210cd2..de7eac79 100644 ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -13,6 +13,8 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - - Native Drama speech can opt into `--silence-floor-dbfs` and `--silence-seconds` to keep audio through a sustained silent tail and finish as `silence`, including an HTTP finish header or stream trailer. The default is off. - - - Native Drama speech can opt into `--silence-mid-s` (below `--silence-seconds`) with an explicit `--silence-mid-min-voiced-s` to finish as `silence` when end of speech falls in a long silent run after enough voiced audio. Configured whole replies carry the decimal `X-Silence-Mid-Trips` count, including zero; streams and errors omit it. The default is off. -+- `serve --silence-redraws N` opts into drawing a whole speech reply the silence guard ended again, as seed salt + k, while `--silence-redraw-budget-s` and the request's `X-Deadline-S` allow; `--silence-redraw-controls LIST` filters redrawn input with the allowlist rules. Streams are never redrawn and the default is off; see [speech redraws](docs/speech-redraws.md). -+- Enabled speech redraws report admitted redraws in `X-Silence-Redraws`, including zero on streams and errors, validate stream deadlines, and wait out a busy engine only until the latest admissible redraw start. - - ## 1.1.1 - -diff --git a/README.md b/README.md -index de8efbb6..08a619f7 100644 ---- a/README.md -+++ b/README.md -@@ -249,6 +249,9 @@ The binary's `capabilities --json` response lists its supported flags and platfo - | `--silence-onset-voiced-s SECONDS` | Drama speech only: the voiced seconds below which `--silence-onset-s` applies; required with it, with no default. Calibrate on public or built-in speech. | - | `--silence-mid-s SECONDS` | Drama speech only: with the silence guard on, finish as `silence` when end of speech falls in a run this long below the floor, after the explicit voiced minimum. Whole replies carry the decimal `X-Silence-Mid-Trips` count, including zero. Must be below `--silence-seconds` and paired with `--silence-mid-min-voiced-s`. Unset by default: off. | - | `--silence-mid-min-voiced-s SECONDS` | Required with `--silence-mid-s`: positive finite cumulative voiced duration before the mid rule applies, including equality. No default; calibrate with the other silence thresholds. | -+| `--silence-redraws N` | Drama speech only: draw a whole reply the silence guard ended again, up to N times, as seed salt + k; N of 1 or more needs the guard. Unset by default: off, as is 0; streams are never redrawn. See [speech redraws](docs/speech-redraws.md). | -+| `--silence-redraw-budget-s SECONDS` | Start a redraw only while elapsed time plus the last take's duration fits; a request's `X-Deadline-S` can lower it. No default budget. | -+| `--silence-redraw-controls LIST` | Redraws speak the input filtered to the listed controls, as `--allowed-controls` filters; an empty list removes every control. | - | `--parallel N` | Admit up to N requests where the engine shares lanes; `auto` is the default. | - | `--prompt-cache-gib GIB` | Retained-prefix budget, 16 GiB by default or less where memory is short; 0 disables it. | - | `--prompt-cache-over-cap` | Permit an explicit prefix budget past what 70% of RAM leaves once the server loads. | -diff --git a/docs/speech-redraws.md b/docs/speech-redraws.md -new file mode 100644 -index 00000000..35376b17 ---- /dev/null -+++ b/docs/speech-redraws.md -@@ -0,0 +1,67 @@ -+# Speech silence redraws -+ -+`tensorfold serve MODEL --silence-floor-dbfs F --silence-seconds S --silence-redraws N` -+draws a whole `POST /v1/audio/speech` reply again when the silence guard ended it -+with `silence`, up to N times (0 to 255). Omitted or 0, it never redraws, never -+reads `X-Deadline-S`, and leaves every reply unchanged. Text models refuse -+`--silence-redraws` at startup, an explicit 0 included. `--silence-redraws` of 1 -+or more needs an enabled silence guard; `--silence-redraw-budget-s` and -+`--silence-redraw-controls` need `--silence-redraws` of 1 or more. -+ -+## Seeds -+ -+Redraw k (1 to N) seeds its prompt with `TENSORFOLD_SEED_SALT` plus k; the first -+take keeps the stock seed, salt plus 0. Redraws are therefore deterministic. With -+the same prompt, redraw k under salt s is the same draw as the first take under -+salt s + k, so a sweep over salts should space compared salts more than N apart. -+ -+## Budget -+ -+A redraw starts only when t + d fits the budget, where t is the time since the -+route received the request, before reading its body, and d is how long the -+previous take ran. The budget is the lower of `--silence-redraw-budget-s` and the -+request's `X-Deadline-S` header, each a positive finite number of seconds. With -+neither, only N bounds redraws. A malformed `X-Deadline-S` is an HTTP 400 while -+redraws are on, including streams. The budget decides starts; a running take is not cut. -+If the engine is busy, submission retries after a sleep until the latest start -+that leaves room for the previous take's duration. Without a deadline or server -+budget, a busy submission keeps the previous audio rather than waiting unboundedly. -+ -+## Reply -+ -+The reply is the last take that finished with audio, sent with its own -+`X-Finish-Reason`. A redraw that ends `stop` or `length` is sent. A redraw that -+ends `silence` replaces the previous take and redrawing continues. When a redraw -+does not start (budget spent, engine busy) or ends without audio, the previous -+take is sent. The server keeps a second PCM buffer, sized like the first, while a -+reply redraws. It logs each redraw's finish reason and why any redraw went unused. -+ -+With `--silence-redraws` above zero, every speech response carries -+`X-Silence-Redraws` as a decimal integer, including zero and error replies. -+It counts redraws admitted to the engine, not trips or attempted submissions; -+a redraw that later fails still counts. Omission or an explicit zero leaves -+the header absent. -+ -+## Controls -+ -+Without `--silence-redraw-controls`, a redraw speaks the same input. With -+`--silence-redraw-controls LIST`, every redraw speaks the input, after any -+`--allowed-controls` filtering, filtered by LIST under the -+[speech control allowlist](speech-controls.md) grammar and stripping rules: -+listed controls stay and unlisted controls are removed. An explicitly empty list -+(`--silence-redraw-controls ''`) instead selects plain speech: every closed -+square-bracket span and documented lowercase scoped tag is replaced by a space, -+a leading `Direction:` line followed by `Line:` is removed, and Unicode -+whitespace is folded and trimmed. If no such span is found, input is unchanged. -+This empty-list setting reproduces the reference plain-controls redraw transform. -+If the filtered input cannot be spoken, for example because nothing is left, -+no redraw starts and the silenced take is sent. No control list is built in. -+ -+## Streams -+ -+Streams are never redrawn. A stream sends each decoded prefix as the engine -+publishes it, so audio may already have reached the client when the guard trips, -+and replacing it would mean buffering the stream and changing its semantics. -+A stream the guard ends finishes with the `silence` trailer exactly as without -+redraws. With redraws enabled, its initial headers include `X-Silence-Redraws: 0`; -+this is not a trailer. Its `X-Deadline-S` is validated even though streams never redraw. -diff --git a/tools/zig/test_speech_flags.py b/tools/zig/test_speech_flags.py -index f3abbdb8..81aa0be1 100644 ---- a/tools/zig/test_speech_flags.py -+++ b/tools/zig/test_speech_flags.py -@@ -7,7 +7,8 @@ import unittest - - ROOT = Path(__file__).resolve().parents[2] - BINARY = ROOT / "zig-out/native/bin/tensorfold-native" --MESSAGE = "--silence-floor-dbfs, --silence-seconds, the silence-onset flags and the silence-mid flags are for Drama speech: serve without them" -+GUARD = "--silence-floor-dbfs, --silence-seconds, the silence-onset flags and the silence-mid flags are for Drama speech: serve without them" -+REDRAWS = "--silence-redraws and its options are for Drama speech: serve without them" - - - @unittest.skipUnless(BINARY.is_file(), "build native before running the CLI admission test") -@@ -15,12 +16,11 @@ class SpeechFlags(unittest.TestCase): - def test_refuses_before_loading(self): - with tempfile.TemporaryDirectory() as folder: - Path(folder, "config.json").write_text('{"model_type":"nemotron_h"}', encoding="utf-8") -- for flags in (("--silence-floor-dbfs=-80",), ("--silence-seconds=0",), -- ("--silence-floor-dbfs=-80", "--silence-seconds=1"), -- ("--silence-floor-dbfs=-80", "--silence-seconds=2", "--silence-onset-s=1", -- "--silence-onset-voiced-s=0.5"), -- ("--silence-floor-dbfs=-80", "--silence-seconds=1", "--silence-mid-s=0.5", -- "--silence-mid-min-voiced-s=1")): -+ for flags, message in ((("--silence-floor-dbfs=-80",), GUARD), (("--silence-seconds=0",), GUARD), -+ (("--silence-floor-dbfs=-80", "--silence-seconds=1"), GUARD), -+ (("--silence-floor-dbfs=-80", "--silence-seconds=2", "--silence-onset-s=1", "--silence-onset-voiced-s=0.5"), GUARD), -+ (("--silence-floor-dbfs=-80", "--silence-seconds=1", "--silence-mid-s=0.5", "--silence-mid-min-voiced-s=1"), GUARD), -+ (("--silence-redraws=0",), REDRAWS)): - with self.subTest(flags=flags): - result = subprocess.run( - [str(BINARY), "serve", folder, *flags], -@@ -28,7 +28,7 @@ class SpeechFlags(unittest.TestCase): - capture_output=True, text=True, timeout=15, - ) - self.assertEqual(result.returncode, 1, result.stderr) -- self.assertIn(MESSAGE, result.stderr) -+ self.assertIn(message, result.stderr) - - - if __name__ == "__main__": -diff --git a/zig/src/server/cli.zig b/zig/src/server/cli.zig -index bb8c97d4..90a07134 100644 ---- a/zig/src/server/cli.zig -+++ b/zig/src/server/cli.zig -@@ -83,6 +83,9 @@ pub const flags = [_]Flag{ - .{ .name = "--silence-onset-voiced-s", .native = true }, - .{ .name = "--silence-mid-s", .native = true }, - .{ .name = "--silence-mid-min-voiced-s", .native = true }, -+ .{ .name = "--silence-redraws", .native = true }, -+ .{ .name = "--silence-redraw-budget-s", .native = true }, -+ .{ .name = "--silence-redraw-controls", .native = true }, - .{ .name = "--compact-at", .native = true }, - .{ .name = "--compact-keep", .native = true }, - .{ .name = "--compact-memory", .native = true }, -@@ -162,6 +165,7 @@ pub const Args = struct { - context: ?i64 = null, - max_frames: ?u32 = null, - silence: api.speech_guard.Config = .{}, -+ redraw: @import("speech_redraw.zig").Policy = .{}, - speed_up: ?[]const u8 = null, // speed-up mode: this Mac's settings for the two-Mac link (Flash Next and the GLMs) - prompt_cache_gib: ?f64 = null, // kept prompt states' budget (0: none; null: what 70% of RAM leaves past the loaded server) - prompt_cache_over_cap: bool = false, // a --prompt-cache-gib past that is kept, not refused -@@ -270,6 +274,7 @@ pub fn parse(a: Allocator, argv: []const []const u8, u: *Usage) error{ Usage, Ou - try ranks(u, a, out); - out.silence.validate() catch |err| - return fail(u, a, "invalid silence guard configuration: {s}", .{@errorName(err)}); -+ out.redraw.validate(out.silence) catch |err| return fail(u, a, "invalid silence redraw configuration: {s}", .{@errorName(err)}); - if (out.vision_urls and !out.vision) return fail(u, a, "--vision-urls needs --vision", .{}); - out.alias = repeated.alias.items; - out.api_key = repeated.keys.items; -@@ -397,6 +402,12 @@ fn apply(a: Allocator, out: *Args, name: []const u8, value: ?[]const u8, u: *Usa - if (n < 16 or n > 16384) return fail(u, a, "argument --vision-image-tokens: expected 16 to 16384 tokens: '{s}'", .{v}); - out.vision_image_tokens = @intCast(n); - out.vision_image_tokens_set = true; -+ } else if (is(name, "--silence-redraws")) { -+ out.redraw.max = std.math.cast(u8, try int(u, a, name, v)) orelse return fail(u, a, "argument --silence-redraws: expected 0 to 255 redraws: '{s}'", .{v}); -+ } else if (is(name, "--silence-redraw-budget-s")) { -+ out.redraw.budget_s = try float(u, a, name, v); -+ } else if (is(name, "--silence-redraw-controls")) { -+ out.redraw.controls = @import("drama_allow.zig").parse(v) catch return fail(u, a, "argument --silence-redraw-controls: expected comma-separated tag names (ASCII words), or an empty list: '{s}'", .{v}); - } - } - -@@ -614,22 +625,3 @@ test "the GPU engine's flags: refused and unlisted unless the backend serves the - for ([_][2][]const u8{ .{ "--rank", "-1" }, .{ "--master-port", "70000" }, .{ "--mtp-drafts", "65" }, .{ "--mtp-confidence", "1.5" }, .{ "--checkpoint-slots", "-1" }, .{ "--policy", "matrix" } }) |g| try std.testing.expectError(error.Usage, gpuFlag(a, &args, g[0], g[1], &u)); - try std.testing.expect(!try gpuFlag(a, &args, "--port", "1", &u)); - } -- --test "silence guard flags are opt-in and reach native open options" { -- var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -- defer arena.deinit(); -- const a = arena.allocator(); -- var usage: Usage = .{}; -- try std.testing.expect(!(try parse(a, &.{"m"}, &usage)).silence.enabled()); -- try std.testing.expect((try parse(a, &.{ "m", "--silence-seconds=0" }, &usage)).silence.refusal("nemotron_h") != null); -- const args = try parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-seconds", "0.5" }, &usage); -- const opened = try request(a, "m", "drama3", args); -- try std.testing.expectEqual(args.silence, opened.silence); -- try std.testing.expect(opened.silence.enabled()); -- try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-seconds", "1" }, &usage)); -- try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-seconds", "nan" }, &usage)); -- const onset = try parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-seconds", "2", "--silence-onset-s", "1", "--silence-onset-voiced-s", "0.5" }, &usage); -- try std.testing.expectEqual(api.speech_guard.Config{ .floor_dbfs = -80, .seconds = 2, .onset_seconds = 1, .onset_voiced_seconds = 0.5 }, (try request(a, "m", "drama3", onset)).silence); -- try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-seconds", "2", "--silence-onset-s", "1" }, &usage)); -- try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-onset-s=0" }, &usage)); --} -diff --git a/zig/src/server/cli_test.zig b/zig/src/server/cli_test.zig -index 7b2c9a11..56346cd5 100644 ---- a/zig/src/server/cli_test.zig -+++ b/zig/src/server/cli_test.zig -@@ -60,6 +60,53 @@ test "voices: CLI defaults and explicit catalog" { - try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--default-voice=../tone" }, &u)); - } - -+test "silence guard flags are opt-in and reach native open options" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ var usage: Usage = .{}; -+ try std.testing.expect(!(try parse(a, &.{"m"}, &usage)).silence.enabled()); -+ try std.testing.expect((try parse(a, &.{ "m", "--silence-seconds=0" }, &usage)).silence.refusal("nemotron_h") != null); -+ const args = try parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-seconds", "0.5" }, &usage); -+ const opened = try request(a, "m", "drama3", args); -+ try std.testing.expectEqual(args.silence, opened.silence); -+ try std.testing.expect(opened.silence.enabled()); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-seconds", "1" }, &usage)); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-seconds", "nan" }, &usage)); -+ const onset = try parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-seconds", "2", "--silence-onset-s", "1", "--silence-onset-voiced-s", "0.5" }, &usage); -+ try std.testing.expectEqual(api.speech_guard.Config{ .floor_dbfs = -80, .seconds = 2, .onset_seconds = 1, .onset_voiced_seconds = 0.5 }, (try request(a, "m", "drama3", onset)).silence); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-floor-dbfs=-80", "--silence-seconds", "2", "--silence-onset-s", "1" }, &usage)); -+ try std.testing.expectError(error.Usage, parse(a, &.{ "m", "--silence-onset-s=0" }, &usage)); -+} -+ -+test "silence redraw flags are opt-in and need a redraw count and the guard" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ var u: Usage = .{}; -+ try std.testing.expectEqualDeep(@import("speech_redraw.zig").Policy{}, (try parse(a, &.{"m"}, &u)).redraw); -+ const guard = [_][]const u8{ "m", "--silence-floor-dbfs=-60", "--silence-seconds=1" }; -+ const on = try parse(a, &(guard ++ .{ "--silence-redraws", "2", "--silence-redraw-budget-s=30", "--silence-redraw-controls=" }), &u); -+ try std.testing.expectEqual(@as(?u8, 2), on.redraw.max); -+ try std.testing.expectEqual(@as(?f64, 30), on.redraw.budget_s); -+ try std.testing.expectEqualStrings("", on.redraw.controls.?.list); -+ const zero = try parse(a, &.{ "m", "--silence-redraws=0" }, &u); -+ try std.testing.expect(zero.redraw.refusal("nemotron_h") != null); -+ try std.testing.expect(zero.redraw.refusal("fish_omni") == null); -+ for ([_][]const []const u8{ -+ &.{ "m", "--silence-redraws", "1" }, -+ &(guard ++ .{ "--silence-redraw-budget-s", "30" }), -+ &(guard ++ .{ "--silence-redraw-controls", "sighs" }), -+ &(guard ++ .{ "--silence-redraws", "256" }), -+ &(guard ++ .{ "--silence-redraws", "1", "--silence-redraw-budget-s", "0" }), -+ &(guard ++ .{ "--silence-redraws", "1", "--silence-redraw-controls", "[sighs]" }), -+ }) |bad| try std.testing.expectError(error.Usage, parse(a, bad, &u)); -+ var out: std.Io.Writer.Allocating = .init(a); -+ try capabilities(&out.writer, .{ .version = "test" }); -+ for ([_][]const u8{ "--silence-redraws", "--silence-redraw-budget-s", "--silence-redraw-controls" }) |flag| -+ try std.testing.expect(std.mem.indexOf(u8, out.written(), try std.fmt.allocPrint(a, "\"{s}\": {{}}", .{flag})) != null); -+} -+ - test "parse and capabilities share the table" { - var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer arena.deinit(); -diff --git a/zig/src/server/main.zig b/zig/src/server/main.zig -index d5d26875..859bd4ca 100644 ---- a/zig/src/server/main.zig -+++ b/zig/src/server/main.zig -@@ -28,6 +28,8 @@ const usage_line = "usage: tensorfold serve [-h] [--host HOST] [--port PORT] [-- - " [--silence-floor-dbfs DBFS] [--silence-seconds SECONDS]" ++ - " [--silence-onset-s SECONDS] [--silence-onset-voiced-s SECONDS]" ++ - " [--silence-mid-s SECONDS] [--silence-mid-min-voiced-s SECONDS]" ++ -+ " [--silence-redraws N]" ++ -+ " [--silence-redraw-budget-s SECONDS] [--silence-redraw-controls LIST]" ++ - " [--compact-at COMPACT_AT] [--compact-keep COMPACT_KEEP]" ++ - " [--compact-memory COMPACT_MEMORY] [--slide] [--slide-graph SLIDE_GRAPH] [--parallel PARALLEL]" ++ - " [--cortheon] [--no-update-check] [--backend {auto,mlx,cuda}] [--device DEVICE]" ++ -@@ -105,7 +107,7 @@ pub fn main(init: std.process.Init) !u8 { - if (e == error.OutOfMemory) return e; - return fail(problem); - }; -- if (args.silence.refusal(model_type)) |why| return fail(why); -+ if (args.silence.refusal(model_type) orelse args.redraw.refusal(model_type)) |why| return fail(why); - // the model folder held while this process reads it, so a rollback refuses it: exclusive with --slide, else shared - var unheld: []const u8 = ""; - const held = api.originals.serving(a, dir, args.slide, &unheld) catch |e| switch (e) { -diff --git a/zig/src/server/reply_text.zig b/zig/src/server/reply_text.zig -index 6af44209..c2fd74fe 100644 ---- a/zig/src/server/reply_text.zig -+++ b/zig/src/server/reply_text.zig -@@ -240,7 +240,7 @@ pub fn isSpaceByte(ch: u8) bool { - return ch == ' ' or (ch >= 0x09 and ch <= 0x0d) or (ch >= 0x1c and ch <= 0x1f); - } - --fn spaceAt(s: []const u8, i: usize) usize { -+pub fn spaceAt(s: []const u8, i: usize) usize { - const c = s[i]; - if (c == ' ' or (c >= 0x09 and c <= 0x0d) or (c >= 0x1c and c <= 0x1f)) return 1; - if (c < 0x80) return 0; -diff --git a/zig/src/server/root.zig b/zig/src/server/root.zig -index bef87e16..95732579 100644 ---- a/zig/src/server/root.zig -+++ b/zig/src/server/root.zig -@@ -43,6 +43,8 @@ test { - _ = @import("status_routes.zig"); - _ = @import("speech.zig"); - _ = @import("speech_stream_test.zig"); -+ _ = @import("speech_redraw.zig"); -+ _ = @import("speech_redraw_test.zig"); - _ = @import("drama_prompt.zig"); - _ = @import("health_test.zig"); - _ = @import("memory_routes.zig"); -diff --git a/zig/src/server/serve.zig b/zig/src/server/serve.zig -index 4dadeb85..93570c9e 100644 ---- a/zig/src/server/serve.zig -+++ b/zig/src/server/serve.zig -@@ -148,6 +148,7 @@ pub fn run(gpa: Allocator, io: std.Io, args: cli.Args, s: Setup) u8 { - const config: server_mod.Config = .{ - .browser = .{ .allowed_hosts = args.allowed_host, .allowed_origins = args.allowed_origin }, - .allowed_controls = args.allowed_controls, -+ .redraw = args.redraw, - .served_name = s.served, - .model_dir = s.model_dir, - .voices_dir = args.voices, -diff --git a/zig/src/server/server.zig b/zig/src/server/server.zig -index 5f888c91..4b1e0955 100644 ---- a/zig/src/server/server.zig -+++ b/zig/src/server/server.zig -@@ -35,6 +35,7 @@ pub const Identity = @import("identity.zig").Identity; - pub const Config = struct { - browser: request_guard.Policy = .{}, - allowed_controls: ?@import("drama_allow.zig").Policy = null, -+ redraw: @import("speech_redraw.zig").Policy = .{}, - sse_keepalive_s: f64 = 15, - served_name: []const u8, - model_dir: []const u8 = "", -diff --git a/zig/src/server/speech.zig b/zig/src/server/speech.zig -index 3b604a8b..14dd54a1 100644 ---- a/zig/src/server/speech.zig -+++ b/zig/src/server/speech.zig -@@ -9,6 +9,7 @@ const openai = @import("openai.zig"); - const pyrepr = @import("pyrepr.zig"); - const reply_text = @import("reply_text.zig"); - const drama_prompt = @import("drama_prompt.zig"); -+const speech_redraw = @import("speech_redraw.zig"); - const ids = @import("ids.zig"); - const log = @import("log.zig"); - const clock = @import("clock.zig"); -@@ -40,7 +41,22 @@ pub const Format = enum { - } - }; - --const Plan = struct { prompt: []const u32, frames: ?[]const [11]i32 = null, seed: u64, format: Format, stream: bool }; -+const Plan = struct { input: []const u8, prompt: []const u32, frames: ?[]const [11]i32 = null, refs: []const @import("drama_prompt").Reference = &.{}, seed: u64, format: Format, stream: bool, budget_s: ?f64 = null }; -+ -+/// One whole take: its audio and why it ended. -+const Take = struct { pcm: []f32, reason: api.Reason }; -+ -+const Metadata = struct { -+ enabled: bool, -+ redraws: u8 = 0, -+ mid: ?u8 = null, -+ -+ fn add(m: Metadata, c: *Conn) !void { -+ if (!m.enabled) return; -+ var buf: [3]u8 = undefined; -+ try c.addHeader("X-Silence-Redraws", std.fmt.bufPrint(&buf, "{d}", .{m.redraws}) catch unreachable); -+ } -+}; - - pub fn post(srv: *Server, c: *Conn, a: Allocator) !void { - const sp = srv.info.speech orelse { -@@ -51,11 +67,15 @@ pub fn post(srv: *Server, c: *Conn, a: Allocator) !void { - var cx: Cx = .{ .a = a }; - const id = try ids.make(a, "speech-", 32); - const received = clock.nowNs(srv.io); -- const raw = http_body.readJson(c, &cx) catch |e| return refuse(c, &cx, id, e); -- const plan = prepare(srv, &cx, sp, raw) catch |e| return refuse(c, &cx, id, e); -+ var metadata: Metadata = .{ .enabled = srv.config.redraw.count() > 0, .mid = if (srv.config.silence_mid) 0 else null }; -+ const raw = http_body.readJson(c, &cx) catch |e| return refuse(c, &cx, id, metadata, e); -+ var plan = prepare(srv, &cx, sp, raw) catch |e| return refuse(c, &cx, id, metadata, e); - // only chunked framing tells a client that a stream which ends early is incomplete -- if (plan.stream and !std.mem.eql(u8, c.version, "HTTP/1.1")) return fail(c, &cx, id, .request, "stream=true requires HTTP/1.1"); -- synthesize(srv, c, &cx, id, sp, plan, received); -+ if (plan.stream and !std.mem.eql(u8, c.version, "HTTP/1.1")) return fail(c, &cx, id, metadata, .request, "stream=true requires HTTP/1.1"); -+ if (srv.config.redraw.count() > 0) -+ plan.budget_s = speech_redraw.budget(srv.config.redraw.budget_s, c.header("X-Deadline-S")) catch -+ return fail(c, &cx, id, metadata, .request, "X-Deadline-S must be a positive number of seconds"); -+ synthesize(srv, c, &cx, id, sp, plan, received, &metadata); - } - - fn listed(names: []const []const u8, key: []const u8) bool { -@@ -110,6 +130,11 @@ fn prepare(srv: *Server, cx: *Cx, sp: api.SpeechInfo, raw: Value) errors.Refused - return cx.fail(.request, drama_prompt.error_prefix ++ " unknown voice {s}; see /v1/voices", .{name}); - return cx.fail(.request, drama_prompt.error_prefix ++ " unknown voice {s}; only the default voice is supported", .{name}); - }; -+ const encoded = try promptOf(srv, cx, sp, input, refs); -+ return .{ .input = input, .prompt = encoded.prompt, .frames = encoded.frames, .refs = refs, .seed = api.seedFor(encoded.prompt, srv.config.seed_salt), .format = format, .stream = stream }; -+} -+ -+fn promptOf(srv: *Server, cx: *Cx, sp: api.SpeechInfo, input: []const u8, refs: []const @import("drama_prompt").Reference) errors.Refused!struct { prompt: []const u32, frames: ?[]const [11]i32 = null } { - if (try drama_prompt.problem(cx.a, input)) |message| return cx.refuse(message); - if (refs.len > 0) { - const frames = @import("drama_voice.zig").build(cx.a, srv.text, input, refs) catch |e| return switch (e) { -@@ -120,7 +145,7 @@ fn prepare(srv: *Server, cx: *Cx, sp: api.SpeechInfo, raw: Value) errors.Refused - return cx.fail(.request, drama_prompt.error_prefix ++ " a prompt of {d} rows exceeds the maximum allowed length of {d} rows", .{ frames.len, sp.max_prompt_tokens }); - const prompt = try cx.a.alloc(u32, frames.len); - for (frames, prompt) |row, *token| token.* = @intCast(row[0]); -- return .{ .prompt = prompt, .frames = frames, .seed = api.seedFor(prompt, srv.config.seed_salt), .format = format, .stream = stream }; -+ return .{ .prompt = prompt, .frames = frames }; - } - const prompt = drama_prompt.encode(cx.a, srv.text, input) catch |e| return switch (e) { - error.OutOfMemory => error.OutOfMemory, -@@ -128,31 +153,37 @@ fn prepare(srv: *Server, cx: *Cx, sp: api.SpeechInfo, raw: Value) errors.Refused - }; - if (prompt.len > sp.max_prompt_tokens) - return cx.fail(.request, drama_prompt.error_prefix ++ " a prompt of {d} rows exceeds the maximum allowed length of {d} rows", .{ prompt.len, sp.max_prompt_tokens }); -- return .{ .prompt = prompt, .seed = api.seedFor(prompt, srv.config.seed_salt), .format = format, .stream = stream }; -+ return .{ .prompt = prompt }; -+} -+ -+/// One frame row a prompt token, the token in the text column. -+fn rows(a: Allocator, prompt: []const u32) error{ OutOfMemory, InvalidToken }![]const [11]i32 { -+ const frames = try a.alloc([11]i32, prompt.len); -+ for (frames, prompt) |*row, token| { -+ row.* = @splat(0); -+ row[0] = std.math.cast(i32, token) orelse return error.InvalidToken; -+ } -+ return frames; - } - - /// One synthesis: submit, then send the whole audio after the finished event, or stream it as the engine publishes it. --fn synthesize(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, plan: Plan, received: i96) void { -+fn synthesize(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, plan: Plan, received: i96, metadata: *Metadata) void { - var held = srv.speech_inflight.load(.acquire); - while (true) { -- if (held >= @max(1, srv.info.lanes)) return fail(c, cx, id, .capacity, "speech output capacity is full"); -+ if (held >= @max(1, srv.info.lanes)) return fail(c, cx, id, metadata.*, .capacity, "speech output capacity is full"); - if (srv.speech_inflight.cmpxchgWeak(held, held + 1, .acq_rel, .acquire)) |now| held = now else break; - } - defer _ = srv.speech_inflight.fetchSub(1, .acq_rel); - const pcm_len = std.math.mul(usize, sp.max_frames, sp.frame_length) catch 0; - if (pcm_len == 0 or sp.sample_rate == 0 or sp.sample_rate > std.math.maxInt(u32) / 2) -- return fail(c, cx, id, .server, "the engine advertises no usable speech output"); -- const pcm = srv.gpa.alloc(f32, pcm_len) catch return fail(c, cx, id, .server, "out of memory"); -+ return fail(c, cx, id, metadata.*, .server, "the engine advertises no usable speech output"); -+ const pcm = srv.gpa.alloc(f32, pcm_len) catch return fail(c, cx, id, metadata.*, .server, "out of memory"); - defer srv.gpa.free(pcm); - var result: api.SpeechResult = .{}; -- const frames = plan.frames orelse blk: { -- const rows = cx.a.alloc([11]i32, plan.prompt.len) catch return fail(c, cx, id, .server, "out of memory"); -- for (rows, plan.prompt) |*row, token| { -- row.* = @splat(0); -- row[0] = std.math.cast(i32, token) orelse return fail(c, cx, id, .request, "invalid speech token"); -- } -- break :blk rows; -- }; -+ const frames = plan.frames orelse (rows(cx.a, plan.prompt) catch |e| return switch (e) { -+ error.OutOfMemory => fail(c, cx, id, metadata.*, .server, "out of memory"), -+ error.InvalidToken => fail(c, cx, id, metadata.*, .request, "invalid speech token"), -+ }); - const request: api.SpeechRequest = .{ - .prompt = plan.prompt, - .frames = frames, -@@ -170,24 +201,96 @@ fn synthesize(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInf - const engine_id = srv.next_id.fetchAdd(1, .monotonic); - if (srv.keepalive) |k| k.begin(); - defer if (srv.keepalive) |k| k.end(); -+ const started = clock.nowNs(srv.io); - srv.engine.submitSpeech(engine_id, &request, .{ .ctx = &box, .event = Mailbox.onEvent }) catch |e| return switch (e) { -- error.Busy => fail(c, cx, id, .capacity, "the engine is busy; retry shortly"), -- error.Closed => fail(c, cx, id, .other, "the scheduler is closed"), -- error.InvalidRequest => fail(c, cx, id, .request, "the engine refused this speech request's prompt, frame limit or sampling"), -- error.Unsupported => fail(c, cx, id, .request, "this model does not support speech synthesis"), -+ error.Busy => fail(c, cx, id, metadata.*, .capacity, "the engine is busy; retry shortly"), -+ error.Closed => fail(c, cx, id, metadata.*, .other, "the scheduler is closed"), -+ error.InvalidRequest => fail(c, cx, id, metadata.*, .request, "the engine refused this speech request's prompt, frame limit or sampling"), -+ error.Unsupported => fail(c, cx, id, metadata.*, .request, "this model does not support speech synthesis"), - }; - // The engine holds request, prompt, pcm and result until finished; no path below returns before that. -- if (plan.stream) return streamReply(srv, c, cx, id, sp, plan, received, &box, engine_id, pcm, &result); -+ if (plan.stream) return streamReply(srv, c, cx, id, sp, plan, received, &box, engine_id, pcm, &result, metadata.*); - if (settle(srv, c, &box, engine_id, null) == null) return left(srv, c, id, plan.prompt.len, received); -- const samples = result.samples; -- if (why(&box, samples, pcm.len)) |message| return fail(c, cx, id, .server, message); -- const data = quantizeInPlace(pcm[0..samples]) catch return fail(c, cx, id, .server, "the synthesized audio has non-finite samples"); -- if (data.len > std.math.maxInt(u32) - 36) return fail(c, cx, id, .server, "the synthesized audio is too long for one reply"); -- sendAudio(c, plan.format, sp.sample_rate, data, box.reason, if (srv.config.silence_mid) @intFromBool(result.silence_mid) else null) catch { -+ if (metadata.mid) |*trips| trips.* +|= @intFromBool(result.silence_mid); -+ if (why(&box, result.samples, pcm.len)) |message| return fail(c, cx, id, metadata.*, .server, message); -+ const take: Take = .{ .pcm = pcm[0..result.samples], .reason = box.reason }; -+ if (take.reason != .silence or srv.config.redraw.count() == 0) return reply(srv, c, cx, id, sp, plan, received, take, metadata.*); -+ const spare = srv.gpa.alloc(f32, pcm.len) catch return reply(srv, c, cx, id, sp, plan, received, unused(id, 1, take, "out of memory"), metadata.*); -+ defer srv.gpa.free(spare); -+ const sent = redraw(srv, c, cx, id, sp, plan, received, request, started, take, spare, metadata) orelse return; -+ reply(srv, c, cx, id, sp, plan, received, sent, metadata.*); -+} -+ -+/// Redraws a silenced take as seed salt + k while redraws and the budget last; the last take with audio, null if left. -+fn redraw(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, plan: Plan, received: i96, first: api.SpeechRequest, started: i96, silenced: Take, spare: []f32, metadata: *Metadata) ?Take { -+ var next = first; -+ if (srv.config.redraw.controls) |controls| { -+ const input = @import("speech_plain.zig").filter(cx.a, controls, plan.input) catch return unused(id, 1, silenced, "out of memory"); -+ if (input.ptr != plan.input.ptr) { -+ const encoded = promptOf(srv, cx, sp, input, plan.refs) catch |e| return unused(id, 1, silenced, if (e == error.OutOfMemory) "out of memory" else cx.message); -+ next.prompt = encoded.prompt; -+ next.frames = encoded.frames orelse (rows(cx.a, next.prompt) catch |e| return unused(id, 1, silenced, @errorName(e))); -+ } -+ } -+ const buffers = [2][]f32{ first.pcm, spare }; -+ var live: usize = 0; -+ var take = silenced; -+ var start = started; -+ for (1..@as(usize, srv.config.redraw.count()) + 1) |k| { -+ const now = clock.nowNs(srv.io); -+ if (!speech_redraw.fits(plan.budget_s, received, start, now)) return unused(id, k, take, "t + d exceeds the budget"); -+ const start_by = if (plan.budget_s) |s| s - clock.seconds(now - start) else null; -+ start = now; -+ var result: api.SpeechResult = .{}; -+ next.pcm = buffers[1 - live]; -+ next.result = &result; -+ next.seed = api.seedFor(next.prompt, srv.config.seed_salt +% @as(i64, @intCast(k))); -+ var box: Mailbox = .{ .io = srv.io, .gpa = srv.gpa }; -+ defer box.deinit(); -+ const engine_id = srv.next_id.fetchAdd(1, .monotonic); -+ submitRedraw(srv, c, engine_id, &next, &box, received, start_by) catch |e| return unused(id, k, take, @errorName(e)); -+ metadata.redraws += 1; -+ if (settle(srv, c, &box, engine_id, null) == null) { -+ left(srv, c, id, plan.prompt.len, received); -+ return null; -+ } -+ if (metadata.mid) |*trips| trips.* +|= @intFromBool(result.silence_mid); -+ if (why(&box, result.samples, next.pcm.len)) |message| return unused(id, k, take, message); -+ live = 1 - live; -+ take = .{ .pcm = buffers[live][0..result.samples], .reason = box.reason }; -+ log.line("speech {s} redraw {d} finish={s}", .{ id, k, @tagName(take.reason) }); -+ if (take.reason != .silence) break; -+ } -+ return take; -+} -+ -+fn submitRedraw(srv: *Server, c: *Conn, id: api.Id, request: *const api.SpeechRequest, box: *Mailbox, received: i96, start_by: ?f64) api.SpeechSubmitError!void { -+ while (true) { -+ if (start_by) |by| if (clock.seconds(clock.nowNs(srv.io) - received) > by) return error.Busy; -+ srv.engine.submitSpeech(id, request, .{ .ctx = box, .event = Mailbox.onEvent }) catch |e| { -+ if (e != error.Busy or start_by == null or c.peerGone()) return e; -+ std.Io.sleep(srv.io, .fromMilliseconds(20), .awake) catch {}; -+ continue; -+ }; -+ return; -+ } -+} -+ -+/// Logs why redraw `k` gave no take; `take` stays the reply. -+fn unused(id: []const u8, k: usize, take: Take, why_not: []const u8) Take { -+ log.line("speech {s} redraw {d} not used: {s}", .{ id, k, why_not }); -+ return take; -+} -+ -+/// Sends a whole take under its finish reason. -+fn reply(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, plan: Plan, received: i96, take: Take, metadata: Metadata) void { -+ const data = quantizeInPlace(take.pcm) catch return fail(c, cx, id, metadata, .server, "the synthesized audio has non-finite samples"); -+ if (data.len > std.math.maxInt(u32) - 36) return fail(c, cx, id, metadata, .server, "the synthesized audio is too long for one reply"); -+ sendAudio(c, plan.format, sp.sample_rate, data, take.reason, metadata) catch { - c.close = true; // a head already started or a client gone: nothing more can be sent on this connection - return; - }; -- log.line("speech {s} prompt={d} samples={d} format={s} finish={s} seconds={d:.2}", .{ id, plan.prompt.len, samples, @tagName(plan.format), @tagName(box.reason), clock.seconds(clock.nowNs(srv.io) - received) }); -+ log.line("speech {s} prompt={d} samples={d} format={s} finish={s} seconds={d:.2}", .{ id, plan.prompt.len, take.pcm.len, @tagName(plan.format), @tagName(take.reason), clock.seconds(clock.nowNs(srv.io) - received) }); - } - - /// The client left before the reply ended; the synthesis was cancelled and waited out. -@@ -208,11 +311,11 @@ fn why(box: *const Mailbox, samples: usize, capacity: usize) ?[]const u8 { - } - - /// An error reply when synthesis ends before any audio; else the chunks, and the terminator only after a complete one. --fn streamReply(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, plan: Plan, received: i96, box: *Mailbox, engine_id: api.Id, pcm: []f32, result: *const api.SpeechResult) void { -- const sent = streamAudio(srv, c, sp, box, engine_id, pcm) orelse return left(srv, c, id, plan.prompt.len, received); -+fn streamReply(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, plan: Plan, received: i96, box: *Mailbox, engine_id: api.Id, pcm: []f32, result: *const api.SpeechResult, metadata: Metadata) void { -+ const sent = streamAudio(srv, c, sp, box, engine_id, pcm, metadata) orelse return left(srv, c, id, plan.prompt.len, received); - const mismatch: ?[]const u8 = if (result.samples != sent.samples) "the finished synthesis does not match the audio it published" else null; - const problem = why(box, result.samples, pcm.len) orelse mismatch; -- if (sent.samples == 0) return fail(c, cx, id, .server, problem orelse "the engine finished without publishing audio"); -+ if (sent.samples == 0) return fail(c, cx, id, metadata, .server, problem orelse "the engine finished without publishing audio"); - if (problem) |message| { - c.close = true; // no terminating chunk: the client's HTTP/1.1 parser reports an incomplete body - log.line("speech {s} stream ended incomplete after samples={d}: {s}", .{ id, sent.samples, message }); -@@ -231,7 +334,7 @@ fn streamReply(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechIn - const Sent = struct { samples: usize = 0, first_ready: i96 = 0, first_written: i96 = 0 }; - - /// Sends everything published since the last send as one chunk, converted in place; null: the client left. --fn streamAudio(srv: *Server, c: *Conn, sp: api.SpeechInfo, box: *Mailbox, engine_id: api.Id, pcm: []f32) ?Sent { -+fn streamAudio(srv: *Server, c: *Conn, sp: api.SpeechInfo, box: *Mailbox, engine_id: api.Id, pcm: []f32, metadata: Metadata) ?Sent { - var sent: Sent = .{}; - var size: [20]u8 = undefined; - while (true) { -@@ -240,7 +343,7 @@ fn streamAudio(srv: *Server, c: *Conn, sp: api.SpeechInfo, box: *Mailbox, engine - const first = sent.samples == 0; - if (first) { - sent.first_ready = clock.nowNs(srv.io); -- startStream(c, sp.sample_rate) catch return abandon(srv, box, engine_id); -+ startStream(c, sp.sample_rate, metadata) catch return abandon(srv, box, engine_id); - } - // the audio event made pcm[..now] final: this thread converts it in place - const bytes = quantizeInPlace(pcm[sent.samples..now]) catch return abandon(srv, box, engine_id); -@@ -282,7 +385,7 @@ fn settle(srv: *Server, c: *Conn, box: *Mailbox, engine_id: api.Id, sent: ?usize - } - - /// 200 with raw PCM16LE in HTTP/1.1 chunks, under the reference's sample-format headers. --fn startStream(c: *Conn, sample_rate: u32) error{Closed}!void { -+fn startStream(c: *Conn, sample_rate: u32, metadata: Metadata) error{Closed}!void { - c.startResponse(200, null) catch return error.Closed; - c.addHeader("Content-Type", Format.pcm.contentType()) catch return error.Closed; - c.addHeader("Transfer-Encoding", "chunked") catch return error.Closed; -@@ -291,6 +394,7 @@ fn startStream(c: *Conn, sample_rate: u32) error{Closed}!void { - c.addHeader("X-Channels", "1") catch return error.Closed; - c.addHeader("X-Bit-Depth", "16") catch return error.Closed; - c.addHeader("Trailer", "X-Finish-Reason") catch return error.Closed; -+ metadata.add(c) catch return error.Closed; - if (c.close) c.addHeader("Connection", "close") catch return error.Closed; - // each chunk leaves at once rather than waiting on the client's acknowledgement of the last (no effect off TCP) - const one: c_int = 1; -@@ -306,30 +410,39 @@ const last_chunk = "0\r\n\r\n"; - const length_chunk = "0\r\nX-Finish-Reason: length\r\n\r\n"; - const silence_chunk = "0\r\nX-Finish-Reason: silence\r\n\r\n"; - --fn refuse(c: *Conn, cx: *Cx, id: []const u8, e: errors.Refused) void { -+fn refuse(c: *Conn, cx: *Cx, id: []const u8, metadata: Metadata, e: errors.Refused) void { - if (e == error.OutOfMemory) { - cx.kind = .server; - cx.message = "out of memory"; - } -- send(c, cx, id); -+ send(c, cx, id, metadata); - } - --fn fail(c: *Conn, cx: *Cx, id: []const u8, kind: errors.Kind, message: []const u8) void { -+fn fail(c: *Conn, cx: *Cx, id: []const u8, metadata: Metadata, kind: errors.Kind, message: []const u8) void { - cx.kind = kind; - cx.message = message; -- send(c, cx, id); -+ send(c, cx, id, metadata); - } - - /// OpenAI's error object, logged first: the access line shows only the status. --fn send(c: *Conn, cx: *Cx, id: []const u8) void { -+fn send(c: *Conn, cx: *Cx, id: []const u8, metadata: Metadata) void { - var buf: [1400]u8 = undefined; - log.line("{s}", .{log.refused(&buf, id, cx.message)}); - const body = openai.errorBody(cx.a, cx, null) catch return; -- routes.sendValue(c, cx.a, cx.status(), openai.wrapError(cx.a, body) catch return); -+ const text = json.stringify(cx.a, openai.wrapError(cx.a, body) catch return, .{}) catch return; -+ c.startResponse(cx.status(), null) catch return; -+ c.addHeader("Content-Type", "application/json") catch return; -+ var len: [24]u8 = undefined; -+ c.addHeader("Content-Length", std.fmt.bufPrint(&len, "{d}", .{text.len}) catch unreachable) catch return; -+ metadata.add(c) catch return; -+ if (c.close) c.addHeader("Connection", "close") catch return; -+ c.finish(text) catch { -+ c.close = true; -+ }; - } - - /// The head, then the WAV header and the samples where they lie: the audio is never copied to prepend a header. --fn sendAudio(c: *Conn, format: Format, sample_rate: u32, data: []const u8, reason: api.Reason, silence_mid: ?u8) error{ Closed, OutOfMemory }!void { -+fn sendAudio(c: *Conn, format: Format, sample_rate: u32, data: []const u8, reason: api.Reason, metadata: Metadata) error{ Closed, OutOfMemory }!void { - const header: ?[wav_header_len]u8 = if (format == .wav) wavHeader(sample_rate, @intCast(data.len)) else null; - const header_len: usize = if (header != null) wav_header_len else 0; - try c.startResponse(200, null); -@@ -337,7 +450,8 @@ fn sendAudio(c: *Conn, format: Format, sample_rate: u32, data: []const u8, reaso - var len: [24]u8 = undefined; - try c.addHeader("Content-Length", std.fmt.bufPrint(&len, "{d}", .{header_len + data.len}) catch unreachable); - try c.addHeader("X-Finish-Reason", @tagName(reason)); -- if (silence_mid) |trips| { -+ try metadata.add(c); -+ if (metadata.mid) |trips| { - var count: [3]u8 = undefined; - try c.addHeader("X-Silence-Mid-Trips", std.fmt.bufPrint(&count, "{d}", .{trips}) catch unreachable); - } -diff --git a/zig/src/server/speech_plain.zig b/zig/src/server/speech_plain.zig -new file mode 100644 -index 00000000..0f2448d3 ---- /dev/null -+++ b/zig/src/server/speech_plain.zig -@@ -0,0 +1,84 @@ -+//! An explicitly empty redraw list requests plain speech rather than another delivery allowlist. -+const std = @import("std"); -+const reply_text = @import("reply_text.zig"); -+const drama_allow = @import("drama_allow.zig"); -+const Allocator = std.mem.Allocator; -+const scoped_tags = [_][]const u8{ "whisper", "emphasis", "soft", "fast", "slow", "stress", "forceful" }; -+const Span = struct { start: usize, end: usize }; -+ -+pub fn filter(a: Allocator, policy: drama_allow.Policy, input: []const u8) Allocator.Error![]const u8 { -+ if (policy.list.len != 0) return drama_allow.filter(a, policy, input); -+ var spans: std.ArrayList(Span) = .empty; -+ var i = directionHead(input); -+ if (i > 0) try spans.append(a, .{ .start = 0, .end = i }); -+ var closes = true; -+ while (i < input.len) { -+ const n = controlAt(input[i..], &closes); -+ if (n == 0) { -+ i += 1; -+ continue; -+ } -+ try spans.append(a, .{ .start = i, .end = i + n }); -+ i += n; -+ } -+ if (spans.items.len == 0) return input; -+ var out: std.ArrayList(u8) = .empty; -+ var gap = false; -+ var k: usize = 0; -+ i = 0; -+ while (i < input.len) { -+ if (k < spans.items.len and spans.items[k].start <= i) { -+ gap = true; -+ i = spans.items[k].end; -+ k += 1; -+ continue; -+ } -+ const n = reply_text.spaceAt(input, i); -+ if (n > 0) { -+ gap = true; -+ i += n; -+ continue; -+ } -+ if (gap and out.items.len > 0) try out.append(a, ' '); -+ gap = false; -+ try out.append(a, input[i]); -+ i += 1; -+ } -+ return out.items; -+} -+ -+fn controlAt(rest: []const u8, closes: *bool) usize { -+ if (rest[0] == '[' and closes.*) { -+ if (std.mem.indexOfScalar(u8, rest, ']')) |end| return end + 1; -+ closes.* = false; -+ } else if (rest[0] == '<') { -+ const name = rest[@as(usize, 1) + @intFromBool(rest.len > 1 and rest[1] == '/') ..]; -+ for (scoped_tags) |tag| if (std.mem.startsWith(u8, name, tag) and name.len > tag.len and name[tag.len] == '>') -+ return rest.len - name.len + tag.len + 1; -+ } -+ return 0; -+} -+ -+fn directionHead(input: []const u8) usize { -+ var i: usize = 0; -+ while (i < input.len) { -+ const n = reply_text.spaceAt(input, i); -+ if (n == 0) break; -+ i += n; -+ } -+ if (!std.mem.startsWith(u8, input[i..], "Direction:")) return 0; -+ const end = std.mem.indexOfScalarPos(u8, input, i, '\n') orelse return 0; -+ return if (std.mem.startsWith(u8, input[end + 1 ..], "Line:")) end + 1 + "Line:".len else 0; -+} -+ -+test "plain redraw without controls preserves input without allocating" { -+ const input = " ordinary\ttext "; -+ const result = try filter(std.testing.failing_allocator, .{}, input); -+ try std.testing.expectEqual(input.ptr, result.ptr); -+ try std.testing.expectEqualStrings(input, result); -+ try std.testing.expectError(error.OutOfMemory, filter(std.testing.failing_allocator, .{}, "[sighs] hi")); -+} -+ -+test { -+ _ = @import("speech_plain_test.zig"); -+} -diff --git a/zig/src/server/speech_plain_test.zig b/zig/src/server/speech_plain_test.zig -new file mode 100644 -index 00000000..4a7e30d0 ---- /dev/null -+++ b/zig/src/server/speech_plain_test.zig -@@ -0,0 +1,26 @@ -+const std = @import("std"); -+const plain = @import("speech_plain.zig"); -+ -+test "empty redraw list matches reference plain controls synthetic goldens" { -+ var arena = std.heap.ArenaAllocator.init(std.testing.allocator); -+ defer arena.deinit(); -+ // Expected strings were produced by executing the reference behaviour on synthetic text. -+ const cases = [_][2][]const u8{ -+ .{ "", "" }, -+ .{ " ordinary\ttext ", " ordinary\ttext " }, -+ .{ "[sighs] hello [unknown]", "hello" }, -+ .{ "a[[sighs]]b", "a ]b" }, -+ .{ "a[unclosed", "a[unclosed" }, -+ .{ "text [sighs]", "text" }, -+ .{ "text.", "text ." }, -+ .{ "\t[sighs]a\n\n b\u{3000}c ", "a b c" }, -+ .{ "Direction: warm, then [slower]\nLine: [sighs] Hello there.", "Hello there ." }, -+ .{ " Direction: note\nNotLine: text", " Direction: note\nNotLine: text" }, -+ .{ "x[pause] [sighs](url) `[sad]`", "x (url) ` `" }, -+ .{ "你好[unknown]世界", "你好 世界" }, -+ .{ "end [sighs]", "end" }, -+ .{ "[sighs][laughs]", "" }, -+ .{ "[note x] tail", "tail" }, -+ }; -+ for (cases) |c| try std.testing.expectEqualStrings(c[1], try plain.filter(arena.allocator(), .{}, c[0])); -+} -diff --git a/zig/src/server/speech_redraw.zig b/zig/src/server/speech_redraw.zig -new file mode 100644 -index 00000000..306294e0 ---- /dev/null -+++ b/zig/src/server/speech_redraw.zig -@@ -0,0 +1,81 @@ -+//! Opt-in redraws of whole speech replies that the silence guard ended. -+const std = @import("std"); -+const api = @import("engine_api"); -+const clock = @import("clock.zig"); -+const drama_allow = @import("drama_allow.zig"); -+ -+pub const Policy = struct { -+ /// --silence-redraws as given; null when omitted, so an explicit 0 still names a speech-only flag. -+ max: ?u8 = null, -+ /// Seconds from receipt that every redraw must fit; null leaves only the client's X-Deadline-S. -+ budget_s: ?f64 = null, -+ /// Null keeps the input; an empty list makes it plain; nonempty lists use the delivery allowlist. -+ controls: ?drama_allow.Policy = null, -+ -+ pub fn count(p: Policy) u8 { -+ return p.max orelse 0; -+ } -+ -+ pub fn validate(p: Policy, silence: api.speech_guard.Config) !void { -+ if (p.budget_s) |s| if (!std.math.isFinite(s) or s <= 0) return error.BadRedrawBudget; -+ if (p.count() == 0 and (p.budget_s != null or p.controls != null)) return error.MissingRedrawCount; -+ if (p.count() > 0 and !silence.enabled()) return error.MissingSilenceGuard; -+ } -+ -+ pub fn refusal(p: Policy, model_type: []const u8) ?[]const u8 { -+ if (p.max != null and !std.mem.eql(u8, model_type, "fish_omni")) -+ return "--silence-redraws and its options are for Drama speech: serve without them"; -+ return null; -+ } -+}; -+ -+/// The lower of the server budget and a request's X-Deadline-S, a positive finite number of seconds. -+pub fn budget(server_s: ?f64, header: ?[]const u8) error{BadDeadline}!?f64 { -+ const text = std.mem.trim(u8, header orelse return server_s, " \t"); -+ const s = std.fmt.parseFloat(f64, text) catch return error.BadDeadline; -+ if (!std.math.isFinite(s) or s <= 0) return error.BadDeadline; -+ return @min(s, server_s orelse s); -+} -+ -+/// Whether t + d fits `budget_s`: t runs from `received` to `now`, d is the last take's run from `started` to `now`. -+pub fn fits(budget_s: ?f64, received: i96, started: i96, now: i96) bool { -+ const limit = budget_s orelse return true; -+ return clock.seconds(now - received) + clock.seconds(now - started) <= limit; -+} -+ -+test "redraw policy is off by default and needs a count and an enabled guard" { -+ const guard: api.speech_guard.Config = .{ .floor_dbfs = -60, .seconds = 1 }; -+ try (Policy{}).validate(.{}); -+ try (Policy{ .max = 2, .budget_s = 30, .controls = .{} }).validate(guard); -+ try std.testing.expectError(error.MissingSilenceGuard, (Policy{ .max = 1 }).validate(.{})); -+ try std.testing.expectError(error.MissingRedrawCount, (Policy{ .budget_s = 30 }).validate(guard)); -+ try std.testing.expectError(error.MissingRedrawCount, (Policy{ .controls = .{} }).validate(guard)); -+ for ([_]f64{ 0, -1, std.math.nan(f64), std.math.inf(f64) }) |bad| -+ try std.testing.expectError(error.BadRedrawBudget, (Policy{ .max = 1, .budget_s = bad }).validate(guard)); -+} -+ -+test "an explicit redraw count, even 0, is refused on text and accepted on Drama speech" { -+ for ([_]Policy{ .{ .max = 0 }, .{ .max = 2 } }) |p| { -+ try std.testing.expect(p.refusal("nemotron_h") != null); -+ try std.testing.expect(p.refusal("fish_omni") == null); -+ } -+ try std.testing.expect((Policy{}).refusal("nemotron_h") == null); -+} -+ -+test "the budget is the lower of the server's and a well-formed X-Deadline-S" { -+ try std.testing.expectEqual(@as(?f64, null), try budget(null, null)); -+ try std.testing.expectEqual(@as(?f64, 120), try budget(120, null)); -+ try std.testing.expectEqual(@as(?f64, 2.5), try budget(null, " 2.5 ")); -+ try std.testing.expectEqual(@as(?f64, 30), try budget(120, "30")); -+ try std.testing.expectEqual(@as(?f64, 120), try budget(120, "600")); -+ for ([_][]const u8{ "", "0", "-3", "nan", "inf", "soon" }) |bad| try std.testing.expectError(error.BadDeadline, budget(120, bad)); -+} -+ -+test "a redraw fits when elapsed time plus the last take's duration is within the budget" { -+ const s = std.time.ns_per_s; -+ try std.testing.expect(fits(null, 0, 0, 1000 * s)); -+ try std.testing.expect(fits(34, 0, 16 * s, 25 * s)); -+ try std.testing.expect(!fits(34, 0, 9 * s, 25 * s)); -+ try std.testing.expect(fits(34, 0, 0, 17 * s)); -+ try std.testing.expect(!fits(34, 0, 0, 17 * s + 1)); -+} -diff --git a/zig/src/server/speech_redraw_test.zig b/zig/src/server/speech_redraw_test.zig -new file mode 100644 -index 00000000..0e432897 ---- /dev/null -+++ b/zig/src/server/speech_redraw_test.zig -@@ -0,0 +1,189 @@ -+//! Silence redraws on the wire, against the scripted engine of speech_stream_test.zig. -+const std = @import("std"); -+const api = @import("engine_api"); -+const harness = @import("speech_stream_test.zig"); -+const drama_allow = @import("drama_allow.zig"); -+const drama_prompt = @import("drama_prompt.zig"); -+const quantizeInPlace = @import("speech.zig").quantizeInPlace; -+const Policy = @import("speech_redraw.zig").Policy; -+const Script = harness.Script; -+const Served = harness.Served; -+const frame = harness.frame; -+const expectHeader = harness.expectHeader; -+ -+const salt = 5; -+ -+fn start(s: *Served, script: *Script, redraw: Policy, headers: []const u8, body: []const u8) !void { -+ try s.open(script, .{ .served_name = "m", .model_ids = &.{"m"}, .seed_salt = salt, .redraw = redraw }, headers, "HTTP/1.1", body, 2000); -+} -+ -+fn whole(comptime input: []const u8) []const u8 { -+ return "{\"model\":\"m\",\"input\":\"" ++ input ++ "\",\"response_format\":\"pcm\"}"; -+} -+ -+/// The seed stock speech gives `input` under salt + `k`. -+fn seed(input: []const u8, k: i64) !u64 { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ var bytes: harness.Bytes = .{}; -+ return api.seedFor(try drama_prompt.encode(arena.allocator(), bytes.text(), input), salt + k); -+} -+ -+/// The reply is one frame of take `k`'s fill. -+fn expectTake(body: []const u8, k: usize) !void { -+ var sample = [_]f32{0.5 / @as(f32, @floatFromInt(k + 1))}; -+ try std.testing.expectEqual(@as(usize, 2 * frame), body.len); -+ try std.testing.expectEqualSlices(u8, try quantizeInPlace(&sample), body[0..2]); -+} -+ -+const Reply = struct { head: []const u8, body: []const u8 }; -+ -+/// Reads the whole reply and waits for the route and the engine, so a failed check never outlives them. -+fn finish(s: *Served, script: *Script) !Reply { -+ defer s.join(script); -+ _ = try s.readHead(); -+ const body = try s.readRest(); -+ return .{ .head = s.got.items[0..s.read_at], .body = body }; -+} -+ -+/// Serves one whole reply and checks its status line, finish reason, take and engine submissions. -+fn expectReply(script: *Script, redraw: Policy, headers: []const u8, comptime input: []const u8, reason: []const u8, take: usize, takes: usize) !void { -+ var s: Served = undefined; -+ try start(&s, script, redraw, headers, whole(input)); -+ defer s.deinit(); -+ const reply = try finish(&s, script); -+ try expectHeader(reply.head, "HTTP/1.1 200"); -+ try expectHeader(reply.head, reason); -+ if (redraw.count() > 0) { -+ var header: [40]u8 = undefined; -+ try expectHeader(reply.head, try std.fmt.bufPrint(&header, "X-Silence-Redraws: {d}\r\n", .{takes - 1})); -+ } else try std.testing.expect(std.mem.indexOf(u8, reply.head, "X-Silence-Redraws") == null); -+ try expectTake(reply.body, take); -+ try std.testing.expectEqual(takes, script.takes); -+ try std.testing.expectEqual(try seed(input, 0), script.seeds[0]); -+} -+ -+test "redraws off, omitted or an explicit 0, send a silenced reply once and never read X-Deadline-S" { -+ for ([_]Policy{ .{}, .{ .max = 0 } }) |off| { -+ var script: Script = .{ .steps = &.{frame}, .reasons = &.{ .silence, .stop }, .inline_ = true }; -+ try expectReply(&script, off, "X-Deadline-S: soon\r\n", "hi", "X-Finish-Reason: silence\r\n", 0, 1); -+ } -+} -+ -+test "a silenced reply is redrawn with seed salt + k until a take finishes" { -+ var script: Script = .{ .steps = &.{frame}, .reasons = &.{ .silence, .silence, .stop }, .inline_ = true }; -+ try expectReply(&script, .{ .max = 3 }, "", "hi", "X-Finish-Reason: stop\r\n", 2, 3); -+ for (script.seeds[0..3], 0..) |got, k| try std.testing.expectEqual(try seed("hi", @intCast(k)), got); -+} -+ -+test "the last silenced take is sent once redraws run out" { -+ var script: Script = .{ .steps = &.{frame}, .reason = .silence, .inline_ = true }; -+ try expectReply(&script, .{ .max = 1 }, "", "hi", "X-Finish-Reason: silence\r\n", 1, 2); -+} -+ -+test "a refused or failed redraw leaves the last take with audio as the reply" { -+ var busy: Script = .{ .steps = &.{frame}, .reason = .silence, .busy_from = 1, .inline_ = true }; -+ try expectReply(&busy, .{ .max = 2 }, "", "hi", "X-Finish-Reason: silence\r\n", 0, 1); -+ var failed: Script = .{ .steps = &.{frame}, .reasons = &.{ .silence, .failed }, .message = "codec failed", .inline_ = true }; -+ try expectReply(&failed, .{ .max = 2 }, "", "hi", "X-Finish-Reason: silence\r\n", 0, 2); -+} -+ -+test "no redraw starts past the server budget or X-Deadline-S" { -+ const cases = [_]struct { Policy, []const u8, usize }{ -+ .{ .{ .max = 1, .budget_s = 1e-9 }, "", 1 }, -+ .{ .{ .max = 1 }, "X-Deadline-S: 1e-9\r\n", 1 }, -+ .{ .{ .max = 1, .budget_s = 60 }, "X-Deadline-S: 1e-9\r\n", 1 }, -+ .{ .{ .max = 1, .budget_s = 1e-9 }, "X-Deadline-S: 60\r\n", 1 }, -+ .{ .{ .max = 1, .budget_s = 60 }, "X-Deadline-S: 60\r\n", 2 }, -+ }; -+ for (cases) |case| { -+ var script: Script = .{ .steps = &.{frame}, .reason = .silence, .inline_ = true }; -+ try expectReply(&script, case[0], case[1], "hi", "X-Finish-Reason: silence\r\n", case[2] - 1, case[2]); -+ } -+} -+ -+test "a malformed X-Deadline-S is refused when redraws are on" { -+ var script: Script = .{ .steps = &.{frame}, .reason = .silence, .inline_ = true }; -+ var s: Served = undefined; -+ try start(&s, &script, .{ .max = 1 }, "X-Deadline-S: 0\r\n", whole("hi")); -+ defer s.deinit(); -+ const reply = try finish(&s, &script); -+ try expectHeader(reply.head, "HTTP/1.1 400"); -+ try expectHeader(reply.head, "X-Silence-Redraws: 0\r\n"); -+ try expectHeader(reply.body, "X-Deadline-S must be a positive number of seconds"); -+ try std.testing.expectEqual(@as(usize, 0), script.takes); -+} -+ -+test "redraw controls filter the redrawn input; an input with nothing left is not redrawn" { -+ var script: Script = .{ .steps = &.{frame}, .reasons = &.{ .silence, .stop }, .inline_ = true }; -+ try expectReply(&script, .{ .max = 1, .controls = try drama_allow.parse("sighs") }, "", "hi [sighs] [laughs] there", "X-Finish-Reason: stop\r\n", 1, 2); -+ try std.testing.expectEqual(try seed("hi [sighs] there", 1), script.seeds[1]); -+ var empty: Script = .{ .steps = &.{frame}, .reasons = &.{ .silence, .stop }, .inline_ = true }; -+ try expectReply(&empty, .{ .max = 1, .controls = try drama_allow.parse("") }, "", "[sighs]", "X-Finish-Reason: silence\r\n", 0, 1); -+} -+ -+test "an empty redraw list seeds the reference plain text" { -+ var script: Script = .{ .steps = &.{frame}, .reasons = &.{ .silence, .stop }, .inline_ = true }; -+ try expectReply(&script, .{ .max = 1, .controls = try drama_allow.parse("") }, "", "[sighs] hello [unknown]", "X-Finish-Reason: stop\r\n", 1, 2); -+ try std.testing.expectEqual(try seed("hello", 1), script.seeds[1]); -+} -+ -+test "streams are never redrawn and report zero redraws" { -+ var script: Script = .{ .steps = &.{frame}, .reasons = &.{ .silence, .stop }, .inline_ = true }; -+ var s: Served = undefined; -+ try start(&s, &script, .{ .max = 2 }, "X-Deadline-S: 60\r\n", harness.stream_body); -+ defer s.deinit(); -+ const reply = try finish(&s, &script); -+ try expectHeader(reply.head, "HTTP/1.1 200"); -+ try expectHeader(reply.head, "X-Silence-Redraws: 0\r\n"); -+ try std.testing.expect(std.mem.endsWith(u8, reply.body, "\r\n0\r\nX-Finish-Reason: silence\r\n\r\n")); -+ try std.testing.expectEqual(@as(usize, 1), script.takes); -+} -+ -+test "stream deadlines are checked only with redraws enabled" { -+ for ([_]Policy{ .{}, .{ .max = 0 }, .{ .max = 1 } }) |policy| { -+ var script: Script = .{ .steps = &.{frame}, .inline_ = true }; -+ var s: Served = undefined; -+ try start(&s, &script, policy, "X-Deadline-S: soon\r\n", harness.stream_body); -+ defer s.deinit(); -+ const reply = try finish(&s, &script); -+ try expectHeader(reply.head, if (policy.count() > 0) "HTTP/1.1 400" else "HTTP/1.1 200"); -+ if (policy.count() > 0) { -+ try expectHeader(reply.head, "X-Silence-Redraws: 0\r\n"); -+ try std.testing.expectEqual(@as(usize, 0), script.takes); -+ } else try std.testing.expect(std.mem.indexOf(u8, reply.head, "X-Silence-Redraws") == null); -+ } -+} -+ -+test "an enabled guard reports zero without a trip and on a request refusal" { -+ var script: Script = .{ .steps = &.{frame}, .inline_ = true }; -+ try expectReply(&script, .{ .max = 1 }, "", "hi", "X-Finish-Reason: stop\r\n", 0, 1); -+ var s: Served = undefined; -+ try start(&s, &script, .{ .max = 1 }, "", "{\"model\":\"m\"}"); -+ defer s.deinit(); -+ const reply = try finish(&s, &script); -+ try expectHeader(reply.head, "HTTP/1.1 400"); -+ try expectHeader(reply.head, "X-Silence-Redraws: 0\r\n"); -+} -+ -+test "a busy redraw is admitted after backoff within its deadline" { -+ var script: Script = .{ -+ .steps = &.{frame}, -+ .reasons = &.{ .silence, .stop }, -+ .inline_ = true, -+ .busy_redraws = 1, -+ }; -+ try expectReply(&script, .{ .max = 1 }, "X-Deadline-S: 60\r\n", "hi", "X-Finish-Reason: stop\r\n", 1, 2); -+ try std.testing.expectEqual(@as(usize, 1), script.busy_replies); -+} -+ -+test "a busy redraw stops at its latest start without counting an unsubmitted take" { -+ var script: Script = .{ -+ .steps = &.{frame}, -+ .reason = .silence, -+ .inline_ = true, -+ .busy_redraws = std.math.maxInt(usize), -+ }; -+ try expectReply(&script, .{ .max = 1 }, "X-Deadline-S: 0.1\r\n", "hi", "X-Finish-Reason: silence\r\n", 0, 1); -+ try std.testing.expect(script.busy_replies > 1); -+} -diff --git a/zig/src/server/speech_stream_test.zig b/zig/src/server/speech_stream_test.zig -index 16065048..bf6d15c9 100644 ---- a/zig/src/server/speech_stream_test.zig -+++ b/zig/src/server/speech_stream_test.zig -@@ -7,15 +7,20 @@ const model_text = @import("model_text.zig"); - const routes = @import("routes.zig"); - const Conn = @import("http_conn.zig").Conn; - --const frame = 2048; -+pub const frame = 2048; - const gpa = std.testing.allocator; - --/// Publishes 0.5-filled steps then finishes: inside submit (`inline_`), else on a thread that may `hold` for `release`. --const Script = struct { -+/// Publishes steps of 0.5 / (take + 1), then finishes: inside submit (`inline_`), else on a thread that may `hold`. -+pub const Script = struct { - steps: []const usize, - reason: api.Reason = .stop, - silence_mid: bool = false, -+ /// Each take's reason, then `reason` for later takes. -+ reasons: []const api.Reason = &.{}, -+ busy_from: usize = std.math.maxInt(usize), - mid_enabled: bool = false, -+ busy_redraws: usize = 0, -+ busy_replies: usize = 0, - message: []const u8 = "", - hold: usize = 0, - inline_: bool = false, -@@ -28,6 +33,8 @@ const Script = struct { - get_voices: bool = false, - expected_rows: ?[]const [11]i32 = null, - rows_equal: bool = false, -+ seeds: [4]u64 = undefined, -+ takes: usize = 0, - - fn engine(e: *Script) api.Engine { - return .{ .ctx = e, .vtable = &.{ .info = info, .submit = submit, .cancel = cancel, .status = status, .memory = memory, .submit_speech = speech } }; -@@ -39,24 +46,33 @@ const Script = struct { - fn speech(ctx: *anyopaque, id: api.Id, request: *const api.SpeechRequest, sink: api.Sink) api.SpeechSubmitError!void { - const e: *Script = @ptrCast(@alignCast(ctx)); - if (e.expected_rows) |expected| e.rows_equal = std.mem.eql(u8, std.mem.sliceAsBytes(expected), std.mem.sliceAsBytes(request.frames)); -- if (e.inline_) return run(e, id, request, sink); -- e.thread = std.Thread.spawn(.{}, run, .{ e, id, request, sink }) catch return error.Busy; -- } -- fn run(e: *Script, id: api.Id, request: *const api.SpeechRequest, sink: api.Sink) void { -+ if (e.takes >= e.busy_from) return error.Busy; -+ if (e.takes > 0 and e.busy_replies < e.busy_redraws) { -+ e.busy_replies += 1; -+ return error.Busy; -+ } -+ const take = e.takes; -+ e.seeds[take] = request.seed; -+ e.takes += 1; -+ if (e.inline_) return run(e, id, request, sink, take); -+ e.thread = std.Thread.spawn(.{}, run, .{ e, id, request, sink, take }) catch return error.Busy; -+ } -+ fn run(e: *Script, id: api.Id, request: *const api.SpeechRequest, sink: api.Sink, take: usize) void { -+ const reason = if (take < e.reasons.len) e.reasons[take] else e.reason; - var done: usize = 0; - for (e.steps, 0..) |n, i| { - if (e.hold > 0 and i == e.hold) { - while (!e.release.load(.acquire)) std.Thread.yield() catch {}; - if (e.cancelled.load(.acquire)) return sink.event(sink.ctx, id, &.{ .finished = .{ .reason = .cancelled } }); - } -- @memset(request.pcm[done..n], 0.5); -+ @memset(request.pcm[done..n], 0.5 / @as(f32, @floatFromInt(take + 1))); - if (request.stream) sink.event(sink.ctx, id, &.{ .audio = n }); - done = n; - } -- if (e.reason == .stop or e.reason == .length or e.reason == .silence) request.result.samples = done; -- request.result.reason = e.reason; - request.result.silence_mid = e.silence_mid; -- sink.event(sink.ctx, id, &.{ .finished = .{ .reason = e.reason, .message = e.message } }); -+ if (reason == .stop or reason == .length or reason == .silence) request.result.samples = done; -+ request.result.reason = reason; -+ sink.event(sink.ctx, id, &.{ .finished = .{ .reason = reason, .message = e.message } }); - } - fn cancel(ctx: *anyopaque, _: api.Id) void { - const e: *Script = @ptrCast(@alignCast(ctx)); -@@ -75,8 +91,8 @@ const Script = struct { - }; - - /// Every byte is its own token: the route's prompt encoding needs no vocabulary. --const Bytes = struct { -- fn text(t: *Bytes) model_text.Text { -+pub const Bytes = struct { -+ pub fn text(t: *Bytes) model_text.Text { - return .{ .ctx = t, .vtable = &.{ .encode = encode, .decode = decode, .token_id = tokenId, .token_string = tokenString, .vocab_size = vocabSize, .eos_ids = eosIds, .render = render, .template_source = templateSource } }; - } - fn encode(_: *anyopaque, a: std.mem.Allocator, input: []const u8, _: bool) model_text.Error![]u32 { -@@ -111,7 +127,7 @@ const Bytes = struct { - }; - - /// One request served on its own thread; the test reads the reply from the client end as it arrives. --const Served = struct { -+pub const Served = struct { - srv: *server.Server, - text: Bytes = .{}, - client: std.c.fd_t, -@@ -124,15 +140,20 @@ const Served = struct { - client_open: bool = true, - - fn start(s: *Served, script: *Script, version: []const u8, body: []const u8, write_ms: i32) !void { -+ return s.open(script, .{ .served_name = "m", .model_ids = &.{"m"}, .silence_mid = script.mid_enabled }, "", version, body, write_ms); -+ } -+ -+ /// Serves `body` under `config`, with `headers` (each ending in CRLF) after Host. -+ pub fn open(s: *Served, script: *Script, config: server.Config, headers: []const u8, version: []const u8, body: []const u8, write_ms: i32) !void { - var pair: [2]std.c.fd_t = undefined; - if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair) != 0) return error.SocketPair; - s.* = .{ .srv = undefined, .client = pair[0], .route_fd = pair[1], .route = undefined, .write_ms = write_ms }; -- s.srv = try server.Server.init(gpa, std.testing.io, script.engine(), s.text.text(), .{ .served_name = "m", .model_ids = &.{"m"}, .silence_mid = script.mid_enabled }, null); -+ s.srv = try server.Server.init(gpa, std.testing.io, script.engine(), s.text.text(), config, null); - s.srv.voices = script.voices; - if (script.output_full) s.srv.speech_inflight.store(s.srv.info.lanes, .release); -- var head: [160]u8 = undefined; -+ var head: [256]u8 = undefined; - const method_path = if (script.get_voices) "GET /v1/voices" else "POST /v1/audio/speech"; -- try writeAll(s.client, try std.fmt.bufPrint(&head, "{s} {s}\r\nHost: t\r\nContent-Length: {d}\r\n\r\n", .{ method_path, version, body.len })); -+ try writeAll(s.client, try std.fmt.bufPrint(&head, "{s} {s}\r\nHost: t\r\n{s}Content-Length: {d}\r\n\r\n", .{ method_path, version, headers, body.len })); - try writeAll(s.client, body); - s.route = try std.Thread.spawn(.{}, serve, .{s}); - } -@@ -149,13 +170,13 @@ const Served = struct { - } - - /// Waits for the route, then for the engine's own thread. -- fn join(s: *Served, script: *Script) void { -+ pub fn join(s: *Served, script: *Script) void { - s.route.join(); - if (script.thread) |t| t.join(); - script.thread = null; - } - -- fn deinit(s: *Served) void { -+ pub fn deinit(s: *Served) void { - if (s.client_open) _ = std.c.close(s.client); - _ = std.c.close(s.route_fd); - s.got.deinit(gpa); -@@ -177,7 +198,7 @@ const Served = struct { - return true; - } - -- fn readHead(s: *Served) ![]const u8 { -+ pub fn readHead(s: *Served) ![]const u8 { - while (std.mem.indexOf(u8, s.got.items, "\r\n\r\n") == null) if (!try s.more(2000)) return error.NoHead; - const end = std.mem.indexOf(u8, s.got.items, "\r\n\r\n").? + 4; - s.read_at = end; -@@ -216,7 +237,7 @@ const Served = struct { - } - - /// The rest of a reply the route has finished writing. -- fn readRest(s: *Served) ![]const u8 { -+ pub fn readRest(s: *Served) ![]const u8 { - while (try s.more(200)) {} - return s.got.items[s.read_at..]; - } -@@ -231,7 +252,7 @@ fn writeAll(fd: std.c.fd_t, data: []const u8) !void { - } - } - --const stream_body = "{\"model\":\"m\",\"input\":\"hi\",\"response_format\":\"pcm\",\"stream\":true}"; -+pub const stream_body = "{\"model\":\"m\",\"input\":\"hi\",\"response_format\":\"pcm\",\"stream\":true}"; - /// `n` frames of 0.5, which quantizes to 16383 (0x3fff), as PCM16LE. - fn pcm(comptime n: usize) [2 * frame * n]u8 { - var bytes: [2 * frame * n]u8 = undefined; -@@ -239,7 +260,7 @@ fn pcm(comptime n: usize) [2 * frame * n]u8 { - return bytes; - } - --fn expectHeader(head: []const u8, line: []const u8) !void { -+pub fn expectHeader(head: []const u8, line: []const u8) !void { - try std.testing.expect(std.mem.indexOf(u8, head, line) != null); - } - diff --git a/engine/patches/0010-feat-speech-redraw-whole-replies-that-stopped-short-.patch b/engine/patches/0010-feat-speech-redraw-whole-replies-that-stopped-short-.patch deleted file mode 100644 index 562aa15..0000000 --- a/engine/patches/0010-feat-speech-redraw-whole-replies-that-stopped-short-.patch +++ /dev/null @@ -1,389 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: plotarmordev <299844489+plotarmordev@users.noreply.github.com> -Date: Wed, 21 Oct 2026 08:17:38 +0800 -Subject: [PATCH 10/11] feat(speech): redraw whole replies that stopped short - of their input - -K6: net diff of pr/short-reply ea98155b over K5. ---- - CHANGELOG.md | 1 + - README.md | 2 + - docs/speech-redraws.md | 50 +++++++++++++++++++---- - zig/src/server/cli.zig | 4 +- - zig/src/server/cli_test.zig | 12 +++++- - zig/src/server/main.zig | 1 + - zig/src/server/speech.zig | 17 ++++---- - zig/src/server/speech_redraw.zig | 57 +++++++++++++++++++++++++-- - zig/src/server/speech_redraw_test.zig | 41 +++++++++++++++++++ - 9 files changed, 162 insertions(+), 23 deletions(-) - -diff --git a/CHANGELOG.md b/CHANGELOG.md -index de7eac79..8e86c370 100644 ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -14,6 +14,7 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - - - Native Drama speech can opt into `--silence-mid-s` (below `--silence-seconds`) with an explicit `--silence-mid-min-voiced-s` to finish as `silence` when end of speech falls in a long silent run after enough voiced audio. Configured whole replies carry the decimal `X-Silence-Mid-Trips` count, including zero; streams and errors omit it. The default is off. - - `serve --silence-redraws N` opts into drawing a whole speech reply the silence guard ended again, as seed salt + k, while `--silence-redraw-budget-s` and the request's `X-Deadline-S` allow; `--silence-redraw-controls LIST` filters redrawn input with the allowlist rules. Streams are never redrawn and the default is off; see [speech redraws](docs/speech-redraws.md). -+- `serve --short-reply-ratio R --short-reply-chars-per-s CPS` also redraws a whole speech reply of 200 characters or more that finished `stop` with less than R × characters / CPS seconds of audio. Neither flag has a default, each needs the other, and CPS is calibrated on the served voice; see [speech redraws](docs/speech-redraws.md). - - Enabled speech redraws report admitted redraws in `X-Silence-Redraws`, including zero on streams and errors, validate stream deadlines, and wait out a busy engine only until the latest admissible redraw start. - - ## 1.1.1 -diff --git a/README.md b/README.md -index 08a619f7..8c710dd7 100644 ---- a/README.md -+++ b/README.md -@@ -252,6 +252,8 @@ The binary's `capabilities --json` response lists its supported flags and platfo - | `--silence-redraws N` | Drama speech only: draw a whole reply the silence guard ended again, up to N times, as seed salt + k; N of 1 or more needs the guard. Unset by default: off, as is 0; streams are never redrawn. See [speech redraws](docs/speech-redraws.md). | - | `--silence-redraw-budget-s SECONDS` | Start a redraw only while elapsed time plus the last take's duration fits; a request's `X-Deadline-S` can lower it. No default budget. | - | `--silence-redraw-controls LIST` | Redraws speak the input filtered to the listed controls, as `--allowed-controls` filters; an empty list removes every control. | -+| `--short-reply-ratio R` | Also redraw a whole reply that finished `stop` with less audio than R (above 0, below 1) of its text's expected length; needs `--silence-redraws` and `--short-reply-chars-per-s`. No default. | -+| `--short-reply-chars-per-s CPS` | Input characters per second of audio in complete replies of the served voice, setting the expected length; needs `--short-reply-ratio`. No default; calibrate on your own voice. | - | `--parallel N` | Admit up to N requests where the engine shares lanes; `auto` is the default. | - | `--prompt-cache-gib GIB` | Retained-prefix budget, 16 GiB by default or less where memory is short; 0 disables it. | - | `--prompt-cache-over-cap` | Permit an explicit prefix budget past what 70% of RAM leaves once the server loads. | -diff --git a/docs/speech-redraws.md b/docs/speech-redraws.md -index 35376b17..4fb62749 100644 ---- a/docs/speech-redraws.md -+++ b/docs/speech-redraws.md -@@ -5,8 +5,9 @@ draws a whole `POST /v1/audio/speech` reply again when the silence guard ended i - with `silence`, up to N times (0 to 255). Omitted or 0, it never redraws, never - reads `X-Deadline-S`, and leaves every reply unchanged. Text models refuse - `--silence-redraws` at startup, an explicit 0 included. `--silence-redraws` of 1 --or more needs an enabled silence guard; `--silence-redraw-budget-s` and --`--silence-redraw-controls` need `--silence-redraws` of 1 or more. -+or more needs an enabled silence guard; `--silence-redraw-budget-s`, -+`--silence-redraw-controls` and the [short reply](#short-replies) flags need -+`--silence-redraws` of 1 or more. - - ## Seeds - -@@ -30,11 +31,12 @@ budget, a busy submission keeps the previous audio rather than waiting unbounded - ## Reply - - The reply is the last take that finished with audio, sent with its own --`X-Finish-Reason`. A redraw that ends `stop` or `length` is sent. A redraw that --ends `silence` replaces the previous take and redrawing continues. When a redraw --does not start (budget spent, engine busy) or ends without audio, the previous --take is sent. The server keeps a second PCM buffer, sized like the first, while a --reply redraws. It logs each redraw's finish reason and why any redraw went unused. -+`X-Finish-Reason`. A redraw that ends `silence`, or a [short](#short-replies) -+`stop`, replaces the previous take and redrawing continues; any other finish is -+sent. When a redraw does not start (budget spent, engine busy) or ends without -+audio, the previous take is sent. The server keeps a second PCM buffer, sized -+like the first, while a reply redraws. It logs each redraw's finish reason and -+why any redraw went unused. - - With `--silence-redraws` above zero, every speech response carries - `X-Silence-Redraws` as a decimal integer, including zero and error replies. -@@ -55,7 +57,39 @@ a leading `Direction:` line followed by `Line:` is removed, and Unicode - whitespace is folded and trimmed. If no such span is found, input is unchanged. - This empty-list setting reproduces the reference plain-controls redraw transform. - If the filtered input cannot be spoken, for example because nothing is left, --no redraw starts and the silenced take is sent. No control list is built in. -+no redraw starts and the first take is sent. No control list is built in. -+ -+## Short replies -+ -+A take can also collapse into an early `stop`: speech ends long before the text -+is spoken, with no silent tail for the guard to trip on. With -+`--short-reply-ratio R` and `--short-reply-chars-per-s CPS`, a whole take that -+ends `stop` with less than R × chars / CPS seconds of audio is redrawn as a -+silenced one is, under the same count, budget and controls. chars counts the -+code points of the text the take spoke, bracketed controls included: the input -+after any `--allowed-controls` filtering, or for a redraw, after any -+`--silence-redraw-controls` filtering. R is above 0 and below 1. Texts of fewer -+than 200 characters are never judged, since a fixed lead-in and tail dominate -+their duration, and neither are `length` finishes. Neither flag has a default; -+each needs the other, and without them no `stop` is redrawn. -+ -+No rate is built in: it depends on the voice, its pace and its language. -+Calibrate both flags on the voice you serve: -+ -+1. Serve it as usual, without the short reply flags. -+2. Send whole replies of 200 characters or more that represent your traffic, -+ controls included. -+3. For each reply that finished `stop` and that you heard to be complete, divide -+ its characters, counted as above, by its seconds of audio: the `samples` of -+ the server's reply log line divided by the sample rate in the WAV header. -+4. Set CPS to the median of those rates. Each complete reply's share is -+ seconds × CPS / chars; set R below the lowest share, with a margin, so that -+ no complete reply would have been redrawn. -+5. Check that the replies you heard cut short fall below R. If they overlap the -+ complete ones, the ratio cannot tell them apart on that voice: leave the -+ flags off. -+ -+Calibrate again when the served voice or the kind of text changes. - - ## Streams - -diff --git a/zig/src/server/cli.zig b/zig/src/server/cli.zig -index 90a07134..95ab710d 100644 ---- a/zig/src/server/cli.zig -+++ b/zig/src/server/cli.zig -@@ -86,6 +86,8 @@ pub const flags = [_]Flag{ - .{ .name = "--silence-redraws", .native = true }, - .{ .name = "--silence-redraw-budget-s", .native = true }, - .{ .name = "--silence-redraw-controls", .native = true }, -+ .{ .name = "--short-reply-ratio", .native = true }, -+ .{ .name = "--short-reply-chars-per-s", .native = true }, - .{ .name = "--compact-at", .native = true }, - .{ .name = "--compact-keep", .native = true }, - .{ .name = "--compact-memory", .native = true }, -@@ -406,7 +408,7 @@ fn apply(a: Allocator, out: *Args, name: []const u8, value: ?[]const u8, u: *Usa - out.redraw.max = std.math.cast(u8, try int(u, a, name, v)) orelse return fail(u, a, "argument --silence-redraws: expected 0 to 255 redraws: '{s}'", .{v}); - } else if (is(name, "--silence-redraw-budget-s")) { - out.redraw.budget_s = try float(u, a, name, v); -- } else if (is(name, "--silence-redraw-controls")) { -+ } else if (is(name, "--short-reply-ratio")) out.redraw.short_ratio = try float(u, a, name, v) else if (is(name, "--short-reply-chars-per-s")) out.redraw.short_chars_per_s = try float(u, a, name, v) else if (is(name, "--silence-redraw-controls")) { - out.redraw.controls = @import("drama_allow.zig").parse(v) catch return fail(u, a, "argument --silence-redraw-controls: expected comma-separated tag names (ASCII words), or an empty list: '{s}'", .{v}); - } - } -diff --git a/zig/src/server/cli_test.zig b/zig/src/server/cli_test.zig -index 56346cd5..b42e5b56 100644 ---- a/zig/src/server/cli_test.zig -+++ b/zig/src/server/cli_test.zig -@@ -86,10 +86,13 @@ test "silence redraw flags are opt-in and need a redraw count and the guard" { - var u: Usage = .{}; - try std.testing.expectEqualDeep(@import("speech_redraw.zig").Policy{}, (try parse(a, &.{"m"}, &u)).redraw); - const guard = [_][]const u8{ "m", "--silence-floor-dbfs=-60", "--silence-seconds=1" }; -- const on = try parse(a, &(guard ++ .{ "--silence-redraws", "2", "--silence-redraw-budget-s=30", "--silence-redraw-controls=" }), &u); -+ const on = try parse(a, &(guard ++ .{ "--silence-redraws", "2", "--silence-redraw-budget-s=30", "--silence-redraw-controls=", "--short-reply-ratio=0.25", "--short-reply-chars-per-s", "12" }), &u); - try std.testing.expectEqual(@as(?u8, 2), on.redraw.max); - try std.testing.expectEqual(@as(?f64, 30), on.redraw.budget_s); - try std.testing.expectEqualStrings("", on.redraw.controls.?.list); -+ try std.testing.expectEqual(@as(?f64, 0.25), on.redraw.short_ratio); -+ try std.testing.expectEqual(@as(?f64, 12), on.redraw.short_chars_per_s); -+ try std.testing.expect(on.redraw.refusal("nemotron_h") != null); - const zero = try parse(a, &.{ "m", "--silence-redraws=0" }, &u); - try std.testing.expect(zero.redraw.refusal("nemotron_h") != null); - try std.testing.expect(zero.redraw.refusal("fish_omni") == null); -@@ -100,10 +103,15 @@ test "silence redraw flags are opt-in and need a redraw count and the guard" { - &(guard ++ .{ "--silence-redraws", "256" }), - &(guard ++ .{ "--silence-redraws", "1", "--silence-redraw-budget-s", "0" }), - &(guard ++ .{ "--silence-redraws", "1", "--silence-redraw-controls", "[sighs]" }), -+ &(guard ++ .{ "--short-reply-ratio", "0.25", "--short-reply-chars-per-s", "12" }), -+ &(guard ++ .{ "--silence-redraws", "1", "--short-reply-ratio", "0.25" }), -+ &(guard ++ .{ "--silence-redraws", "1", "--short-reply-chars-per-s", "12" }), -+ &(guard ++ .{ "--silence-redraws", "1", "--short-reply-ratio", "1", "--short-reply-chars-per-s", "12" }), -+ &(guard ++ .{ "--silence-redraws", "1", "--short-reply-ratio", "0.25", "--short-reply-chars-per-s", "0" }), - }) |bad| try std.testing.expectError(error.Usage, parse(a, bad, &u)); - var out: std.Io.Writer.Allocating = .init(a); - try capabilities(&out.writer, .{ .version = "test" }); -- for ([_][]const u8{ "--silence-redraws", "--silence-redraw-budget-s", "--silence-redraw-controls" }) |flag| -+ for ([_][]const u8{ "--silence-redraws", "--silence-redraw-budget-s", "--silence-redraw-controls", "--short-reply-ratio", "--short-reply-chars-per-s" }) |flag| - try std.testing.expect(std.mem.indexOf(u8, out.written(), try std.fmt.allocPrint(a, "\"{s}\": {{}}", .{flag})) != null); - } - -diff --git a/zig/src/server/main.zig b/zig/src/server/main.zig -index 859bd4ca..ac6dd791 100644 ---- a/zig/src/server/main.zig -+++ b/zig/src/server/main.zig -@@ -30,6 +30,7 @@ const usage_line = "usage: tensorfold serve [-h] [--host HOST] [--port PORT] [-- - " [--silence-mid-s SECONDS] [--silence-mid-min-voiced-s SECONDS]" ++ - " [--silence-redraws N]" ++ - " [--silence-redraw-budget-s SECONDS] [--silence-redraw-controls LIST]" ++ -+ " [--short-reply-ratio R] [--short-reply-chars-per-s CPS]" ++ - " [--compact-at COMPACT_AT] [--compact-keep COMPACT_KEEP]" ++ - " [--compact-memory COMPACT_MEMORY] [--slide] [--slide-graph SLIDE_GRAPH] [--parallel PARALLEL]" ++ - " [--cortheon] [--no-update-check] [--backend {auto,mlx,cuda}] [--device DEVICE]" ++ -diff --git a/zig/src/server/speech.zig b/zig/src/server/speech.zig -index 14dd54a1..e40df84f 100644 ---- a/zig/src/server/speech.zig -+++ b/zig/src/server/speech.zig -@@ -214,27 +214,28 @@ fn synthesize(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInf - if (metadata.mid) |*trips| trips.* +|= @intFromBool(result.silence_mid); - if (why(&box, result.samples, pcm.len)) |message| return fail(c, cx, id, metadata.*, .server, message); - const take: Take = .{ .pcm = pcm[0..result.samples], .reason = box.reason }; -- if (take.reason != .silence or srv.config.redraw.count() == 0) return reply(srv, c, cx, id, sp, plan, received, take, metadata.*); -+ if (srv.config.redraw.count() == 0 or !srv.config.redraw.trips(take.reason, take.pcm.len, sp.sample_rate, plan.input)) return reply(srv, c, cx, id, sp, plan, received, take, metadata.*); - const spare = srv.gpa.alloc(f32, pcm.len) catch return reply(srv, c, cx, id, sp, plan, received, unused(id, 1, take, "out of memory"), metadata.*); - defer srv.gpa.free(spare); - const sent = redraw(srv, c, cx, id, sp, plan, received, request, started, take, spare, metadata) orelse return; - reply(srv, c, cx, id, sp, plan, received, sent, metadata.*); - } - --/// Redraws a silenced take as seed salt + k while redraws and the budget last; the last take with audio, null if left. --fn redraw(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, plan: Plan, received: i96, first: api.SpeechRequest, started: i96, silenced: Take, spare: []f32, metadata: *Metadata) ?Take { -+/// Redraws a tripped take as seed salt + k while redraws and the budget last; the last take with audio, null if left. -+fn redraw(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, plan: Plan, received: i96, first: api.SpeechRequest, started: i96, tripped: Take, spare: []f32, metadata: *Metadata) ?Take { - var next = first; -+ var input = plan.input; - if (srv.config.redraw.controls) |controls| { -- const input = @import("speech_plain.zig").filter(cx.a, controls, plan.input) catch return unused(id, 1, silenced, "out of memory"); -+ input = @import("speech_plain.zig").filter(cx.a, controls, plan.input) catch return unused(id, 1, tripped, "out of memory"); - if (input.ptr != plan.input.ptr) { -- const encoded = promptOf(srv, cx, sp, input, plan.refs) catch |e| return unused(id, 1, silenced, if (e == error.OutOfMemory) "out of memory" else cx.message); -+ const encoded = promptOf(srv, cx, sp, input, plan.refs) catch |e| return unused(id, 1, tripped, if (e == error.OutOfMemory) "out of memory" else cx.message); - next.prompt = encoded.prompt; -- next.frames = encoded.frames orelse (rows(cx.a, next.prompt) catch |e| return unused(id, 1, silenced, @errorName(e))); -+ next.frames = encoded.frames orelse (rows(cx.a, next.prompt) catch |e| return unused(id, 1, tripped, @errorName(e))); - } - } - const buffers = [2][]f32{ first.pcm, spare }; - var live: usize = 0; -- var take = silenced; -+ var take = tripped; - var start = started; - for (1..@as(usize, srv.config.redraw.count()) + 1) |k| { - const now = clock.nowNs(srv.io); -@@ -259,7 +260,7 @@ fn redraw(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, p - live = 1 - live; - take = .{ .pcm = buffers[live][0..result.samples], .reason = box.reason }; - log.line("speech {s} redraw {d} finish={s}", .{ id, k, @tagName(take.reason) }); -- if (take.reason != .silence) break; -+ if (!srv.config.redraw.trips(take.reason, take.pcm.len, sp.sample_rate, input)) break; - } - return take; - } -diff --git a/zig/src/server/speech_redraw.zig b/zig/src/server/speech_redraw.zig -index 306294e0..1496e67b 100644 ---- a/zig/src/server/speech_redraw.zig -+++ b/zig/src/server/speech_redraw.zig -@@ -1,4 +1,4 @@ --//! Opt-in redraws of whole speech replies that the silence guard ended. -+//! Opt-in redraws of whole speech replies that the silence guard ended or that stopped short of their input. - const std = @import("std"); - const api = @import("engine_api"); - const clock = @import("clock.zig"); -@@ -11,6 +11,10 @@ pub const Policy = struct { - budget_s: ?f64 = null, - /// Null keeps the input; an empty list makes it plain; nonempty lists use the delivery allowlist. - controls: ?drama_allow.Policy = null, -+ /// A stop with less audio than this share of the input's expected length is redrawn; needs `short_chars_per_s`. -+ short_ratio: ?f64 = null, -+ /// Input characters per second of audio in complete replies of the served voice; the expected length's rate. -+ short_chars_per_s: ?f64 = null, - - pub fn count(p: Policy) u8 { - return p.max orelse 0; -@@ -18,7 +22,11 @@ pub const Policy = struct { - - pub fn validate(p: Policy, silence: api.speech_guard.Config) !void { - if (p.budget_s) |s| if (!std.math.isFinite(s) or s <= 0) return error.BadRedrawBudget; -- if (p.count() == 0 and (p.budget_s != null or p.controls != null)) return error.MissingRedrawCount; -+ if (p.short_ratio) |r| if (!(r > 0 and r < 1)) return error.BadShortReplyRatio; -+ if (p.short_chars_per_s) |c| if (!std.math.isFinite(c) or c <= 0) return error.BadShortReplyRate; -+ if (p.short_ratio != null and p.short_chars_per_s == null) return error.MissingShortReplyRate; -+ if (p.short_chars_per_s != null and p.short_ratio == null) return error.MissingShortReplyRatio; -+ if (p.count() == 0 and (p.budget_s != null or p.controls != null or p.short_ratio != null)) return error.MissingRedrawCount; - if (p.count() > 0 and !silence.enabled()) return error.MissingSilenceGuard; - } - -@@ -27,8 +35,23 @@ pub const Policy = struct { - return "--silence-redraws and its options are for Drama speech: serve without them"; - return null; - } -+ -+ /// Whether a take of `input` that ended `reason` with `samples` at `rate` is redrawn: silenced, or a short stop. -+ pub fn trips(p: Policy, reason: api.Reason, samples: usize, rate: u32, input: []const u8) bool { -+ if (reason == .silence) return true; -+ const ratio = p.short_ratio orelse return false; -+ // bytes bound code points from above, so a short input is never counted -+ if (reason != .stop or input.len < short_min_chars) return false; -+ const chars = std.unicode.utf8CountCodepoints(input) catch return false; -+ if (chars < short_min_chars) return false; -+ const seconds = @as(f64, @floatFromInt(samples)) / @as(f64, @floatFromInt(rate)); -+ return seconds < ratio * @as(f64, @floatFromInt(chars)) / p.short_chars_per_s.?; -+ } - }; - -+/// Inputs of fewer characters are never judged short: a fixed lead-in and tail dominate their duration. -+pub const short_min_chars = 200; -+ - /// The lower of the server budget and a request's X-Deadline-S, a positive finite number of seconds. - pub fn budget(server_s: ?f64, header: ?[]const u8) error{BadDeadline}!?f64 { - const text = std.mem.trim(u8, header orelse return server_s, " \t"); -@@ -46,12 +69,38 @@ pub fn fits(budget_s: ?f64, received: i96, started: i96, now: i96) bool { - test "redraw policy is off by default and needs a count and an enabled guard" { - const guard: api.speech_guard.Config = .{ .floor_dbfs = -60, .seconds = 1 }; - try (Policy{}).validate(.{}); -- try (Policy{ .max = 2, .budget_s = 30, .controls = .{} }).validate(guard); -+ try (Policy{ .max = 2, .budget_s = 30, .controls = .{}, .short_ratio = 0.25, .short_chars_per_s = 10 }).validate(guard); - try std.testing.expectError(error.MissingSilenceGuard, (Policy{ .max = 1 }).validate(.{})); - try std.testing.expectError(error.MissingRedrawCount, (Policy{ .budget_s = 30 }).validate(guard)); - try std.testing.expectError(error.MissingRedrawCount, (Policy{ .controls = .{} }).validate(guard)); -- for ([_]f64{ 0, -1, std.math.nan(f64), std.math.inf(f64) }) |bad| -+ try std.testing.expectError(error.MissingRedrawCount, (Policy{ .short_ratio = 0.25, .short_chars_per_s = 10 }).validate(guard)); -+ try std.testing.expectError(error.MissingShortReplyRate, (Policy{ .max = 1, .short_ratio = 0.25 }).validate(guard)); -+ try std.testing.expectError(error.MissingShortReplyRatio, (Policy{ .max = 1, .short_chars_per_s = 10 }).validate(guard)); -+ for ([_]f64{ 0, -1, std.math.nan(f64), std.math.inf(f64) }) |bad| { - try std.testing.expectError(error.BadRedrawBudget, (Policy{ .max = 1, .budget_s = bad }).validate(guard)); -+ try std.testing.expectError(error.BadShortReplyRatio, (Policy{ .max = 1, .short_ratio = bad, .short_chars_per_s = 10 }).validate(guard)); -+ try std.testing.expectError(error.BadShortReplyRate, (Policy{ .max = 1, .short_ratio = 0.25, .short_chars_per_s = bad }).validate(guard)); -+ } -+ try std.testing.expectError(error.BadShortReplyRatio, (Policy{ .max = 1, .short_ratio = 1, .short_chars_per_s = 10 }).validate(guard)); -+} -+ -+test "a stop of enough characters trips only under the ratio of its expected length; silence always trips" { -+ const p: Policy = .{ .max = 1, .short_ratio = 0.25, .short_chars_per_s = 10 }; -+ // 200 characters at 10 a second are 20 s expected: under a ratio of 0.25, a stop under 5 s of audio is short -+ const letters: [200]u8 = @splat('a'); -+ const input: []const u8 = &letters; -+ try std.testing.expect(p.trips(.stop, 4_999, 1000, input)); -+ try std.testing.expect(!p.trips(.stop, 5_000, 1000, input)); -+ try std.testing.expect(!p.trips(.length, 1, 1000, input)); -+ try std.testing.expect(p.trips(.silence, 60_000, 1000, input)); -+ try std.testing.expect(!p.trips(.stop, 1, 1000, input[1..])); -+ // characters are code points: 199 of two bytes each are too few, 200 are enough -+ const accents: [200][2]u8 = @splat(.{ 0xc3, 0xa9 }); -+ try std.testing.expect(!p.trips(.stop, 1, 1000, std.mem.sliceAsBytes(accents[1..]))); -+ try std.testing.expect(p.trips(.stop, 1, 1000, std.mem.sliceAsBytes(&accents))); -+ const off: Policy = .{ .max = 1 }; -+ try std.testing.expect(!off.trips(.stop, 1, 1000, input)); -+ try std.testing.expect(off.trips(.silence, 1, 1000, input)); - } - - test "an explicit redraw count, even 0, is refused on text and accepted on Drama speech" { -diff --git a/zig/src/server/speech_redraw_test.zig b/zig/src/server/speech_redraw_test.zig -index 0e432897..d8057a17 100644 ---- a/zig/src/server/speech_redraw_test.zig -+++ b/zig/src/server/speech_redraw_test.zig -@@ -122,6 +122,47 @@ test "redraw controls filter the redrawn input; an input with nothing left is no - try expectReply(&empty, .{ .max = 1, .controls = try drama_allow.parse("") }, "", "[sighs]", "X-Finish-Reason: silence\r\n", 0, 1); - } - -+/// 200 characters, the fewest a stop is judged short on. -+const long: []const u8 = &@as([200]u8, @splat('a')); -+ -+/// The rate at which one take, a frame at 44100 Hz, is exactly a quarter of `long`'s expected length. -+const quarter: f64 = 0.25 * 200 * 44100 / @as(f64, frame); -+ -+fn short(chars_per_s: f64) Policy { -+ return .{ .max = 2, .short_ratio = 0.25, .short_chars_per_s = chars_per_s }; -+} -+ -+test "a stop under the ratio of its expected length is redrawn with seed salt + k while redraws last" { -+ var script: Script = .{ .steps = &.{frame}, .inline_ = true }; -+ try expectReply(&script, short(quarter / 2), "", long, "X-Finish-Reason: stop\r\n", 2, 3); -+ for (script.seeds[0..3], 0..) |got, k| try std.testing.expectEqual(try seed(long, @intCast(k)), got); -+} -+ -+test "a stop at or above the ratio of its expected length is sent" { -+ for ([_]f64{ quarter, 2 * quarter }) |rate| { -+ var script: Script = .{ .steps = &.{frame}, .inline_ = true }; -+ try expectReply(&script, short(rate), "", long, "X-Finish-Reason: stop\r\n", 0, 1); -+ } -+} -+ -+test "a length finish, or a stop of fewer than 200 characters, is never judged short" { -+ var length: Script = .{ .steps = &.{frame}, .reason = .length, .inline_ = true }; -+ try expectReply(&length, short(quarter / 2), "", long, "X-Finish-Reason: length\r\n", 0, 1); -+ var fewer: Script = .{ .steps = &.{frame}, .inline_ = true }; -+ try expectReply(&fewer, short(quarter / 2), "", long[1..], "X-Finish-Reason: stop\r\n", 0, 1); -+} -+ -+test "a redraw is judged short by the input it spoke after its controls" { -+ var script: Script = .{ .steps = &.{frame}, .inline_ = true }; -+ const p: Policy = .{ .max = 2, .controls = try drama_allow.parse(""), .short_ratio = 0.25, .short_chars_per_s = quarter / 2 }; -+ try expectReply(&script, p, "", @as([195]u8, @splat('a')) ++ " [sighs]", "X-Finish-Reason: stop\r\n", 1, 2); -+} -+ -+test "without the short reply flags a short stop is sent once" { -+ var script: Script = .{ .steps = &.{frame}, .inline_ = true }; -+ try expectReply(&script, .{ .max = 1 }, "", long, "X-Finish-Reason: stop\r\n", 0, 1); -+} -+ - test "an empty redraw list seeds the reference plain text" { - var script: Script = .{ .steps = &.{frame}, .reasons = &.{ .silence, .stop }, .inline_ = true }; - try expectReply(&script, .{ .max = 1, .controls = try drama_allow.parse("") }, "", "[sighs] hello [unknown]", "X-Finish-Reason: stop\r\n", 1, 2); diff --git a/engine/patches/0011-feat-speech-redraw-a-whole-reply-once-when-a-phrase-.patch b/engine/patches/0011-feat-speech-redraw-a-whole-reply-once-when-a-phrase-.patch deleted file mode 100644 index 9d19f16..0000000 --- a/engine/patches/0011-feat-speech-redraw-a-whole-reply-once-when-a-phrase-.patch +++ /dev/null @@ -1,1380 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: plotarmordev <299844489+plotarmordev@users.noreply.github.com> -Date: Wed, 21 Oct 2026 08:17:38 +0800 -Subject: [PATCH 11/11] feat(speech): redraw a whole reply once when a phrase - opens far above the others - -K8: net diff of pr/register-guard 5933a1d4 over K6, with the cutover integration's resolutions (ca60e9c8). ---- - CHANGELOG.md | 6 +- - README.md | 7 +- - docs/speech-redraws.md | 113 +++++++-- - tools/zig/test_speech_flags.py | 5 +- - zig/src/server/cli.zig | 55 +---- - zig/src/server/cli_test.zig | 24 ++ - zig/src/server/main.zig | 1 + - zig/src/server/root.zig | 1 + - zig/src/server/serve.zig | 1 + - zig/src/server/server.zig | 1 + - zig/src/server/speech.zig | 113 +++++---- - zig/src/server/speech_metadata.zig | 29 +++ - zig/src/server/speech_redraw.zig | 20 +- - zig/src/server/speech_redraw_test.zig | 36 ++- - zig/src/server/speech_register.zig | 157 ++++++++++++ - zig/src/server/speech_register_log.zig | 16 ++ - zig/src/server/speech_register_test.zig | 288 ++++++++++++++++++++++ - zig/src/server/speech_stream_test.zig | 7 +- - zig/src/server/speech_unmeasured_test.zig | 40 +++ - 19 files changed, 792 insertions(+), 128 deletions(-) - create mode 100644 zig/src/server/speech_metadata.zig - create mode 100644 zig/src/server/speech_register.zig - create mode 100644 zig/src/server/speech_register_log.zig - create mode 100644 zig/src/server/speech_register_test.zig - create mode 100644 zig/src/server/speech_unmeasured_test.zig - -diff --git a/CHANGELOG.md b/CHANGELOG.md -index 8e86c370..2f408d40 100644 ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -4,18 +4,22 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - - ## Unreleased - -+- Combined silence onset and mid settings append their clauses to the base startup sentence in onset-then-mid order; the mid clause includes its required voiced minimum. - - Drama 3 on CUDA prefills bounded row chunks in each shared round. `TF_DRAMA_PREFILL_ROWS` selects 1 through 1024 rows (default 128); the generation tool uses `--max-rows`. Batched projections and attention can change rounding relative to single-row prefill; oracle checks now compare the selected prompt chunks at their reference-row offsets. - - Drama 3 serving accepts `--max-frames` to bound synthesis frames, preserving the stock cap when omitted. - - `serve --allowed-controls LIST` opts into stripping unlisted bracketed speech controls before input validation and tokenization. Omission preserves the input unchanged; see [speech controls](docs/speech-controls.md) for the open-vocabulary name grammar and malformed-input policy. - - Drama speech serving accepts opt-in attested voice catalogs with `--voices`, `--default-voice` and `GET /v1/voices`, using the family's reference prompt builder. Catalog loading honors the parser's inclusive file-size bound and reports startup refusal reasons. - - Native Drama speech can opt into `--silence-onset-s` with `--silence-onset-voiced-s` to end a synthesis as `silence` once that many seconds run silent while it has voiced less than the given minimum. Both are off unless given together, and need the silence guard. -+- Register redraws that trip silence or short reenter the remaining retry pipeline before take selection, preserving the held audio and separate admission counts. - - - Native Drama speech can opt into `--silence-floor-dbfs` and `--silence-seconds` to keep audio through a sustained silent tail and finish as `silence`, including an HTTP finish header or stream trailer. The default is off. - --- Native Drama speech can opt into `--silence-mid-s` (below `--silence-seconds`) with an explicit `--silence-mid-min-voiced-s` to finish as `silence` when end of speech falls in a long silent run after enough voiced audio. Configured whole replies carry the decimal `X-Silence-Mid-Trips` count, including zero; streams and errors omit it. The default is off. - - `serve --silence-redraws N` opts into drawing a whole speech reply the silence guard ended again, as seed salt + k, while `--silence-redraw-budget-s` and the request's `X-Deadline-S` allow; `--silence-redraw-controls LIST` filters redrawn input with the allowlist rules. Streams are never redrawn and the default is off; see [speech redraws](docs/speech-redraws.md). - - `serve --short-reply-ratio R --short-reply-chars-per-s CPS` also redraws a whole speech reply of 200 characters or more that finished `stop` with less than R × characters / CPS seconds of audio. Neither flag has a default, each needs the other, and CPS is calibrated on the served voice; see [speech redraws](docs/speech-redraws.md). -+- `serve --register-redraw-st ST --register-redraw-high PERCENT --register-high-hz HZ` opts into drawing a whole speech reply once more when a phrase after the first opens ST semitones above the median of the other phrase openings with PERCENT of its voiced frames above HZ, and sends the take with the smaller jump. The flags have no defaults and are set together; see [speech redraws](docs/speech-redraws.md#register) to calibrate them on your voice. -+- Native Drama speech can opt into `--silence-mid-s` (below `--silence-seconds`) with an explicit `--silence-mid-min-voiced-s` to finish as `silence` when end of speech falls in a long silent run after enough voiced audio. Configured whole replies carry the decimal `X-Silence-Mid-Trips` count, including zero; streams and errors omit it. The default is off. - - Enabled speech redraws report admitted redraws in `X-Silence-Redraws`, including zero on streams and errors, validate stream deadlines, and wait out a busy engine only until the latest admissible redraw start. -+- Configured whole speech replies report latched register trips and admitted register redraws in `X-Register-Trip` and `X-Register-Redraws`, including zero; register retries are excluded from silence retry counts. Register selection retains ties and silence finishes, permits a smaller-jump length finish, and permits unmeasured redraws only with the silence guard enabled. - - ## 1.1.1 - -diff --git a/README.md b/README.md -index 8c710dd7..06c0f3d3 100644 ---- a/README.md -+++ b/README.md -@@ -247,13 +247,16 @@ The binary's `capabilities --json` response lists its supported flags and platfo - | `--silence-seconds SECONDS` | Drama speech only: stop after this continuous duration below the explicit floor, retaining audio and reporting `silence`. Unset by default: off; calibrate above intentional pauses. | - | `--silence-onset-s SECONDS` | Drama speech only: with the silence guard on, stop once this many seconds run below its floor while the synthesis has voiced less than `--silence-onset-voiced-s`, retaining audio and reporting `silence`. Off unless given; must be below `--silence-seconds`. | - | `--silence-onset-voiced-s SECONDS` | Drama speech only: the voiced seconds below which `--silence-onset-s` applies; required with it, with no default. Calibrate on public or built-in speech. | --| `--silence-mid-s SECONDS` | Drama speech only: with the silence guard on, finish as `silence` when end of speech falls in a run this long below the floor, after the explicit voiced minimum. Whole replies carry the decimal `X-Silence-Mid-Trips` count, including zero. Must be below `--silence-seconds` and paired with `--silence-mid-min-voiced-s`. Unset by default: off. | --| `--silence-mid-min-voiced-s SECONDS` | Required with `--silence-mid-s`: positive finite cumulative voiced duration before the mid rule applies, including equality. No default; calibrate with the other silence thresholds. | - | `--silence-redraws N` | Drama speech only: draw a whole reply the silence guard ended again, up to N times, as seed salt + k; N of 1 or more needs the guard. Unset by default: off, as is 0; streams are never redrawn. See [speech redraws](docs/speech-redraws.md). | - | `--silence-redraw-budget-s SECONDS` | Start a redraw only while elapsed time plus the last take's duration fits; a request's `X-Deadline-S` can lower it. No default budget. | - | `--silence-redraw-controls LIST` | Redraws speak the input filtered to the listed controls, as `--allowed-controls` filters; an empty list removes every control. | - | `--short-reply-ratio R` | Also redraw a whole reply that finished `stop` with less audio than R (above 0, below 1) of its text's expected length; needs `--silence-redraws` and `--short-reply-chars-per-s`. No default. | - | `--short-reply-chars-per-s CPS` | Input characters per second of audio in complete replies of the served voice, setting the expected length; needs `--short-reply-ratio`. No default; calibrate on your own voice. | -+| `--register-redraw-st ST` | Drama speech only: draw a whole reply once more when a phrase after the first opens ST semitones above the other phrases' median opening; needs the two flags below. Unset by default: off, with no default for any of the three. See [speech redraws](docs/speech-redraws.md#register). | -+| `--register-redraw-high PERCENT` | The share of that phrase's voiced frames that must lie above `--register-high-hz`. | -+| `--register-high-hz HZ` | The pitch line, 75 to 600 Hz, that marks a high register; calibrate it on your own voice. | -+| `--silence-mid-s SECONDS` | Drama speech only: with the silence guard on, finish as `silence` when end of speech falls in a run this long below the floor, after the explicit voiced minimum. Whole replies carry the decimal `X-Silence-Mid-Trips` count, including zero. Must be below `--silence-seconds` and paired with `--silence-mid-min-voiced-s`. Unset by default: off. | -+| `--silence-mid-min-voiced-s SECONDS` | Required with `--silence-mid-s`: positive finite cumulative voiced duration before the mid rule applies, including equality. No default; calibrate with the other silence thresholds. | - | `--parallel N` | Admit up to N requests where the engine shares lanes; `auto` is the default. | - | `--prompt-cache-gib GIB` | Retained-prefix budget, 16 GiB by default or less where memory is short; 0 disables it. | - | `--prompt-cache-over-cap` | Permit an explicit prefix budget past what 70% of RAM leaves once the server loads. | -diff --git a/docs/speech-redraws.md b/docs/speech-redraws.md -index 4fb62749..b396fb8d 100644 ---- a/docs/speech-redraws.md -+++ b/docs/speech-redraws.md -@@ -1,20 +1,22 @@ --# Speech silence redraws -+# Speech redraws - - `tensorfold serve MODEL --silence-floor-dbfs F --silence-seconds S --silence-redraws N` - draws a whole `POST /v1/audio/speech` reply again when the silence guard ended it --with `silence`, up to N times (0 to 255). Omitted or 0, it never redraws, never --reads `X-Deadline-S`, and leaves every reply unchanged. Text models refuse --`--silence-redraws` at startup, an explicit 0 included. `--silence-redraws` of 1 --or more needs an enabled silence guard; `--silence-redraw-budget-s`, --`--silence-redraw-controls` and the [short reply](#short-replies) flags need --`--silence-redraws` of 1 or more. -+with `silence`, up to N times (0 to 255). Omitted or 0, with the register guard -+off, it never redraws, never reads `X-Deadline-S`, and leaves every reply -+unchanged. Text models refuse `--silence-redraws` at startup, an explicit 0 -+included, and the register flags. `--silence-redraws` of 1 or more needs an -+enabled silence guard; `--silence-redraw-controls` and the [short reply](#short-replies) -+flags need `--silence-redraws` of 1 or more, and `--silence-redraw-budget-s` -+needs it or the [register guard](#register). - - ## Seeds - - Redraw k (1 to N) seeds its prompt with `TENSORFOLD_SEED_SALT` plus k; the first - take keeps the stock seed, salt plus 0. Redraws are therefore deterministic. With - the same prompt, redraw k under salt s is the same draw as the first take under --salt s + k, so a sweep over salts should space compared salts more than N apart. -+salt s + k, so a sweep over salts should space compared salts more than N apart, -+or N + 1 with the register guard. - - ## Budget - -@@ -22,8 +24,9 @@ A redraw starts only when t + d fits the budget, where t is the time since the - route received the request, before reading its body, and d is how long the - previous take ran. The budget is the lower of `--silence-redraw-budget-s` and the - request's `X-Deadline-S` header, each a positive finite number of seconds. With --neither, only N bounds redraws. A malformed `X-Deadline-S` is an HTTP 400 while --redraws are on, including streams. The budget decides starts; a running take is not cut. -+neither, only the retry counts bound redraws. A malformed `X-Deadline-S` is an -+HTTP 400 while silence redraws or the register guard are on, including streams. -+The budget decides starts; a running take is not cut. - If the engine is busy, submission retries after a sleep until the latest start - that leaves room for the previous take's duration. Without a deadline or server - budget, a busy submission keeps the previous audio rather than waiting unboundedly. -@@ -40,24 +43,33 @@ why any redraw went unused. - - With `--silence-redraws` above zero, every speech response carries - `X-Silence-Redraws` as a decimal integer, including zero and error replies. --It counts redraws admitted to the engine, not trips or attempted submissions; --a redraw that later fails still counts. Omission or an explicit zero leaves --the header absent. -+It counts silence and short retries admitted to the engine, not register retries, -+trips or attempted submissions. A redraw that later fails still counts. -+Omission or an explicit zero leaves the header absent. -+ -+With `--silence-mid-s` and `--silence-mid-min-voiced-s` configured, whole success -+also carries `X-Silence-Mid-Trips` as a decimal count, including zero. -+It accumulates mid-ending trips over all completed takes, including discarded -+register redraws, saturating at the counter limit. Streams and errors omit it. - - ## Controls - - Without `--silence-redraw-controls`, a redraw speaks the same input. With --`--silence-redraw-controls LIST`, every redraw speaks the input, after any -+`--silence-redraw-controls LIST`, silence and short redraws speak the input, after any - `--allowed-controls` filtering, filtered by LIST under the - [speech control allowlist](speech-controls.md) grammar and stripping rules: --listed controls stay and unlisted controls are removed. An explicitly empty list -+listed controls stay normalized and unlisted recognized delivery controls are -+removed, while contextual brackets remain text. An explicitly empty list - (`--silence-redraw-controls ''`) instead selects plain speech: every closed - square-bracket span and documented lowercase scoped tag is replaced by a space, - a leading `Direction:` line followed by `Line:` is removed, and Unicode - whitespace is folded and trimmed. If no such span is found, input is unchanged. - This empty-list setting reproduces the reference plain-controls redraw transform. - If the filtered input cannot be spoken, for example because nothing is left, --no redraw starts and the first take is sent. No control list is built in. -+no redraw starts and the first take is sent. No allowlist is built in. -+A direct register redraw keeps the current prompt; it does not apply the silence -+redraw filter. If a silence or short redraw already filtered that prompt, the -+register redraw inherits it. - - ## Short replies - -@@ -91,11 +103,74 @@ Calibrate both flags on the voice you serve: - - Calibrate again when the served voice or the kind of text changes. - -+## Register -+ -+`--register-redraw-st ST --register-redraw-high PERCENT --register-high-hz HZ` -+draws a whole reply once more when a phrase after the first opens far above the -+others in pitch, and sends the take with the smaller jump. The three flags have -+no defaults and are given together: ST above 0, PERCENT above 0 to 100, HZ 75 to -+600. Without them no reply is measured. -+ -+The guard measures takes that do not trip the silence or short retry rule. It reads pitch every 10 ms -+by normalized cross-correlation over 20 ms windows of the audio averaged down to -+8 to 16 kHz. This autocorrelation detector uses Praat's published To Pitch (ac) -+parameter defaults: 75 to 600 Hz, a voicing -+threshold of 0.45, a silence threshold of 0.03 of the take's peak and an octave -+cost of 0.01. Unvoiced runs of 0.25 s part phrases, a phrase voiced for less than -+0.25 s is skipped, and a phrase's opening is the median pitch of its first 0.25 s -+voiced. A phrase after the first trips when its opening is ST semitones or more -+above the median opening of the other phrases and PERCENT or more of its voiced -+frames lie above HZ. The take's jump is the largest such excess. Audio sampled -+below 8 kHz, all-zero audio, and takes with fewer than two retained phrases are -+not measured. A measured take with no qualifying jump counts as zero. -+The detector is not Praat's analysis: its values can differ from Praat's. -+ -+A take that trips is drawn once more, as the next seed, salt + k after any -+silence redraws, if t + d fits the budget. The redraw is sent only if it does not -+trip silence or short and has a strictly smaller jump; ties retain the first take. -+A `length` redraw can replace a `stop` take. An unmeasured redraw can win only -+when the silence guard is enabled; with it off, the first take is retained. -+An unmeasurable first take does not trip. -+The log gives the jump, the redraw's finish and why a first take is retained. -+ -+A register redraw that trips silence or short reenters the silence retry loop with its -+remaining count and the same request budget. Only after that loop ends is its -+last take compared with the held register take. The held audio is never -+overwritten by those retries. Exhausting silence retries does not make a -+silenced or short take eligible for register measurement. -+ -+With the register guard configured, whole successful replies always carry -+`X-Register-Trip` and `X-Register-Redraws`, each a single `0` or `1`. -+Trip is latched when any measured take trips. Redraw counts engine admission, -+even when the first take is retained. Streams, error replies and guard-off -+replies omit both headers. Register retries do not increment `X-Silence-Redraws`. -+ -+### Calibrating on your voice -+ -+The thresholds describe a voice's pitch, so no value suits every voice. Calibrate -+them on the voice you serve: -+ -+1. Serve without the register flags, keep a set of whole replies, and mark those -+ with a phrase that sounds stitched in from a higher register. -+2. Choose trial HZ, ST and PERCENT values and serve the same inputs and seed salts -+ with the flags enabled. Calibrate against the server's own `register jump=` -+ log lines, not Praat measurements. -+3. Compare logged trips with the marked replies. Raise thresholds when ordinary -+ phrases trip; lower them when marked high phrases do not trip. The log reports -+ qualifying jumps only, so absence of a line does not establish a measured zero. -+4. Repeat on held-out inputs before using the thresholds for redraw selection. -+ - ## Streams - --Streams are never redrawn. A stream sends each decoded prefix as the engine --publishes it, so audio may already have reached the client when the guard trips, --and replacing it would mean buffering the stream and changing its semantics. -+Streams are never redrawn or measured. A stream sends each decoded prefix as the -+engine publishes it, so audio may already have reached the client when a guard -+trips, and replacing it would mean buffering the stream and changing its semantics. - A stream the guard ends finishes with the `silence` trailer exactly as without - redraws. With redraws enabled, its initial headers include `X-Silence-Redraws: 0`; - this is not a trailer. Its `X-Deadline-S` is validated even though streams never redraw. -+ -+Register and mid-trip headers are absent. `X-Deadline-S` is validated when -+silence redraws or the register guard are configured, without cancelling a stream. -+Whole replies carry `X-Finish-Reason: stop`, `length` or `silence`. A stream -+declares `Trailer: X-Finish-Reason` and sends that trailer only for `length` -+or `silence`; a normal stop ends with the empty final chunk. -diff --git a/tools/zig/test_speech_flags.py b/tools/zig/test_speech_flags.py -index 81aa0be1..4ef385de 100644 ---- a/tools/zig/test_speech_flags.py -+++ b/tools/zig/test_speech_flags.py -@@ -8,7 +8,8 @@ import unittest - ROOT = Path(__file__).resolve().parents[2] - BINARY = ROOT / "zig-out/native/bin/tensorfold-native" - GUARD = "--silence-floor-dbfs, --silence-seconds, the silence-onset flags and the silence-mid flags are for Drama speech: serve without them" --REDRAWS = "--silence-redraws and its options are for Drama speech: serve without them" -+REDRAWS = "--silence-redraws, the register flags and their options are for Drama speech: serve without them" -+REGISTER = ("--register-redraw-st=6", "--register-redraw-high=50", "--register-high-hz=300") - - - @unittest.skipUnless(BINARY.is_file(), "build native before running the CLI admission test") -@@ -20,7 +21,7 @@ class SpeechFlags(unittest.TestCase): - (("--silence-floor-dbfs=-80", "--silence-seconds=1"), GUARD), - (("--silence-floor-dbfs=-80", "--silence-seconds=2", "--silence-onset-s=1", "--silence-onset-voiced-s=0.5"), GUARD), - (("--silence-floor-dbfs=-80", "--silence-seconds=1", "--silence-mid-s=0.5", "--silence-mid-min-voiced-s=1"), GUARD), -- (("--silence-redraws=0",), REDRAWS)): -+ (("--silence-redraws=0",), REDRAWS), (REGISTER, REDRAWS)): - with self.subTest(flags=flags): - result = subprocess.run( - [str(BINARY), "serve", folder, *flags], -diff --git a/zig/src/server/cli.zig b/zig/src/server/cli.zig -index 95ab710d..a9dab5b5 100644 ---- a/zig/src/server/cli.zig -+++ b/zig/src/server/cli.zig -@@ -82,12 +82,15 @@ pub const flags = [_]Flag{ - .{ .name = "--silence-onset-s", .native = true }, - .{ .name = "--silence-onset-voiced-s", .native = true }, - .{ .name = "--silence-mid-s", .native = true }, -- .{ .name = "--silence-mid-min-voiced-s", .native = true }, - .{ .name = "--silence-redraws", .native = true }, - .{ .name = "--silence-redraw-budget-s", .native = true }, - .{ .name = "--silence-redraw-controls", .native = true }, - .{ .name = "--short-reply-ratio", .native = true }, - .{ .name = "--short-reply-chars-per-s", .native = true }, -+ .{ .name = "--register-redraw-st", .native = true }, -+ .{ .name = "--register-redraw-high", .native = true }, -+ .{ .name = "--register-high-hz", .native = true }, -+ .{ .name = "--silence-mid-min-voiced-s", .native = true }, - .{ .name = "--compact-at", .native = true }, - .{ .name = "--compact-keep", .native = true }, - .{ .name = "--compact-memory", .native = true }, -@@ -276,7 +279,7 @@ pub fn parse(a: Allocator, argv: []const []const u8, u: *Usage) error{ Usage, Ou - try ranks(u, a, out); - out.silence.validate() catch |err| - return fail(u, a, "invalid silence guard configuration: {s}", .{@errorName(err)}); -- out.redraw.validate(out.silence) catch |err| return fail(u, a, "invalid silence redraw configuration: {s}", .{@errorName(err)}); -+ out.redraw.validate(out.silence) catch |err| return fail(u, a, "invalid redraw configuration: {s}", .{@errorName(err)}); - if (out.vision_urls and !out.vision) return fail(u, a, "--vision-urls needs --vision", .{}); - out.alias = repeated.alias.items; - out.api_key = repeated.keys.items; -@@ -310,24 +313,6 @@ fn apply(a: Allocator, out: *Args, name: []const u8, value: ?[]const u8, u: *Usa - return std.mem.eql(u8, x, y); - } - }.f; -- if (is(name, "--allowed-controls")) { -- out.allowed_controls = @import("drama_allow.zig").parse(v) catch -- return fail(u, a, "argument --allowed-controls: expected comma-separated UTF-8 names without square brackets: '{s}'", .{v}); -- return; -- } -- if (is(name, "--silence-floor-dbfs")) { -- out.silence.floor_dbfs = try float(u, a, name, v); -- return; -- } -- if (is(name, "--silence-seconds")) { -- out.silence.seconds = try float(u, a, name, v); -- return; -- } -- if (is(name, "--silence-mid-s") or is(name, "--silence-mid-min-voiced-s")) { -- const seconds = try float(u, a, name, v); -- if (is(name, "--silence-mid-s")) out.silence.mid_seconds = seconds else out.silence.mid_min_voiced_s = seconds; -- return; -- } - if (is(name, "--allowed-host")) { - if (!@import("request_guard.zig").validHost(v)) return fail(u, a, "argument --allowed-host: expected a hostname or bracketed IP without a port: '{s}'", .{v}); - return repeated.hosts.append(a, v); -@@ -338,46 +323,34 @@ fn apply(a: Allocator, out: *Args, name: []const u8, value: ?[]const u8, u: *Usa - } - if (try cudaFlag(a, out, name, v, u) or try gpuFlag(a, out, name, v, u)) return; - if (try recallFlag(a, out, name, v, u)) return; -- if (is(name, "--voices")) { -- out.voices = v; -- return; -- } - if (is(name, "--default-voice")) { - if (!std.mem.eql(u8, v, "default") and !@import("drama_voice.zig").validName(v)) - return fail(u, a, "invalid --default-voice: {s}", .{v}); - out.default_voice = v; - return; - } -- if (is(name, "--chat-template")) { -- out.chat_template = v; -- return; -- } - if (is(name, "--max-frames")) { - const n = try int(u, a, name, v); - if (n < 1 or n > 8190) return fail(u, a, "argument --max-frames: expected 1 through 8190: '{s}'", .{v}); - out.max_frames = @intCast(n); - return; - } -- if (is(name, "--silence-onset-s")) out.silence.onset_seconds = try float(u, a, name, v) else if (is(name, "--silence-onset-voiced-s")) out.silence.onset_voiced_seconds = try float(u, a, name, v) else if (is(name, "--host")) out.host = v else if (is(name, "--port")) { -+ if (is(name, "--allowed-controls")) out.allowed_controls = @import("drama_allow.zig").parse(v) catch -+ return fail(u, a, "argument --allowed-controls: expected comma-separated UTF-8 names without square brackets: '{s}'", .{v}) else if (is(name, "--silence-floor-dbfs")) out.silence.floor_dbfs = try float(u, a, name, v) else if (is(name, "--silence-seconds")) out.silence.seconds = try float(u, a, name, v) else if (is(name, "--silence-mid-s")) out.silence.mid_seconds = try float(u, a, name, v) else if (is(name, "--silence-mid-min-voiced-s")) out.silence.mid_min_voiced_s = try float(u, a, name, v) else if (is(name, "--voices")) out.voices = v else if (is(name, "--chat-template")) out.chat_template = v else if (is(name, "--silence-onset-s")) out.silence.onset_seconds = try float(u, a, name, v) else if (is(name, "--silence-onset-voiced-s")) out.silence.onset_voiced_seconds = try float(u, a, name, v) else if (is(name, "--host")) out.host = v else if (is(name, "--port")) -+ { - const p = try int(u, a, name, v); - if (p < 0 or p > 65535) return fail(u, a, "argument --port: invalid port: '{s}'", .{v}); - out.port = @intCast(p); - } else if (is(name, "--name")) out.name = v else if (is(name, "--alias")) try repeated.alias.append(a, v) else if (is(name, "--api-key")) try repeated.keys.append(a, v) else if (is(name, "--api-key-file")) out.api_key_file = v else if (is(name, "--metrics-open")) out.metrics_open = true else if (is(name, "--dashboard")) out.dashboard = true else if (is(name, "--context")) out.context = try int(u, a, name, v) else if (is(name, "--speed-up")) out.speed_up = v else if (is(name, "--prompt-cache-gib")) out.prompt_cache_gib = try gib(u, a, name, v) else if (is(name, "--prompt-cache-over-cap")) out.prompt_cache_over_cap = true else if (is(name, "--learn")) out.learn = true else if (is(name, "--learn-dir")) { - out.learn = true; - out.learn_dir = v; -- } else if (is(name, "--learn-min-free-gib")) { -- out.learn_min_free_gib = try gib(u, a, name, v); -- } else if (is(name, "--learn-gib")) { -+ } else if (is(name, "--learn-min-free-gib")) out.learn_min_free_gib = try gib(u, a, name, v) else if (is(name, "--learn-gib")) { - out.learn = true; - out.learn_gib = try gib(u, a, name, v); - } else if (is(name, "--learn-conversations")) { - out.learn = true; - out.learn_conversations = true; -- } else if (is(name, "--learn-daily-gib")) { -- out.learn_daily_gib = try gib(u, a, name, v); -- } else if (is(name, "--prompt-seed")) { -- out.prompt_seed = true; -- } else if (is(name, "--max-tokens")) out.max_tokens = try int(u, a, name, v) else if (is(name, "--temperature")) out.temperature = try float(u, a, name, v) else if (is(name, "--top-p")) out.top_p = try float(u, a, name, v) else if (is(name, "--top-k")) out.top_k = try int(u, a, name, v) else if (is(name, "--min-p")) out.min_p = try float(u, a, name, v) else if (is(name, "--thinking")) out.thinking = true else if (is(name, "--no-thinking")) out.thinking = false else if (is(name, "--reasoning-effort")) out.reasoning_effort = v else if (is(name, "--thinking-budget")) out.thinking_budget = try int(u, a, name, v) else if (is(name, "--loop-guard")) out.loop_guard = true else if (is(name, "--drafter")) out.drafter = v else if (is(name, "--drafter-bits")) out.drafter_bits = @intCast(try int(u, a, name, v)) else if (is(name, "--record")) out.record = v else if (is(name, "--kv-cache")) out.kv_cache = v else if (is(name, "--no-drafts")) out.no_drafts = true else if (is(name, "--keep-warm")) out.keep_warm = try int(u, a, name, v) else if (is(name, "--compact-at")) { -+ } else if (is(name, "--learn-daily-gib")) out.learn_daily_gib = try gib(u, a, name, v) else if (is(name, "--prompt-seed")) out.prompt_seed = true else if (is(name, "--max-tokens")) out.max_tokens = try int(u, a, name, v) else if (is(name, "--temperature")) out.temperature = try float(u, a, name, v) else if (is(name, "--top-p")) out.top_p = try float(u, a, name, v) else if (is(name, "--top-k")) out.top_k = try int(u, a, name, v) else if (is(name, "--min-p")) out.min_p = try float(u, a, name, v) else if (is(name, "--thinking")) out.thinking = true else if (is(name, "--no-thinking")) out.thinking = false else if (is(name, "--reasoning-effort")) out.reasoning_effort = v else if (is(name, "--thinking-budget")) out.thinking_budget = try int(u, a, name, v) else if (is(name, "--loop-guard")) out.loop_guard = true else if (is(name, "--drafter")) out.drafter = v else if (is(name, "--drafter-bits")) out.drafter_bits = @intCast(try int(u, a, name, v)) else if (is(name, "--record")) out.record = v else if (is(name, "--kv-cache")) out.kv_cache = v else if (is(name, "--no-drafts")) out.no_drafts = true else if (is(name, "--keep-warm")) out.keep_warm = try int(u, a, name, v) else if (is(name, "--compact-at")) { - if (std.mem.eql(u8, v, "auto")) out.compact_auto = true else { - const f = try float(u, a, name, v); - if (!(f > 0 and f <= 1)) return fail(u, a, "argument --compact-at: expected auto or a fraction in (0, 1]: '{s}'", .{v}); -@@ -406,11 +379,9 @@ fn apply(a: Allocator, out: *Args, name: []const u8, value: ?[]const u8, u: *Usa - out.vision_image_tokens_set = true; - } else if (is(name, "--silence-redraws")) { - out.redraw.max = std.math.cast(u8, try int(u, a, name, v)) orelse return fail(u, a, "argument --silence-redraws: expected 0 to 255 redraws: '{s}'", .{v}); -- } else if (is(name, "--silence-redraw-budget-s")) { -- out.redraw.budget_s = try float(u, a, name, v); -- } else if (is(name, "--short-reply-ratio")) out.redraw.short_ratio = try float(u, a, name, v) else if (is(name, "--short-reply-chars-per-s")) out.redraw.short_chars_per_s = try float(u, a, name, v) else if (is(name, "--silence-redraw-controls")) { -- out.redraw.controls = @import("drama_allow.zig").parse(v) catch return fail(u, a, "argument --silence-redraw-controls: expected comma-separated tag names (ASCII words), or an empty list: '{s}'", .{v}); -- } -+ } else if (is(name, "--silence-redraw-budget-s")) out.redraw.budget_s = try float(u, a, name, v) else if (is(name, "--short-reply-ratio")) out.redraw.short_ratio = try float(u, a, name, v) else if (is(name, "--short-reply-chars-per-s")) out.redraw.short_chars_per_s = try float(u, a, name, v) else if (is(name, "--silence-redraw-controls")) { -+ out.redraw.controls = @import("drama_allow.zig").parse(v) catch return fail(u, a, "argument --silence-redraw-controls: expected comma-separated UTF-8 names without square brackets: '{s}'", .{v}); -+ } else if (is(name, "--register-redraw-st")) out.redraw.register.st = try float(u, a, name, v) else if (is(name, "--register-redraw-high")) out.redraw.register.high = try float(u, a, name, v) else if (is(name, "--register-high-hz")) out.redraw.register.high_hz = try float(u, a, name, v); - } - - /// --recall and the flags that imply it; false for any other flag. -diff --git a/zig/src/server/cli_test.zig b/zig/src/server/cli_test.zig -index b42e5b56..1a81131a 100644 ---- a/zig/src/server/cli_test.zig -+++ b/zig/src/server/cli_test.zig -@@ -115,6 +115,30 @@ test "silence redraw flags are opt-in and need a redraw count and the guard" { - try std.testing.expect(std.mem.indexOf(u8, out.written(), try std.fmt.allocPrint(a, "\"{s}\": {{}}", .{flag})) != null); - } - -+test "register flags are opt-in, all or none, and may set a redraw budget without silence redraws" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ var u: Usage = .{}; -+ try std.testing.expect(!(try parse(a, &.{"m"}, &u)).redraw.register.on()); -+ const rule = [_][]const u8{ "m", "--register-redraw-st", "6", "--register-redraw-high=50", "--register-high-hz", "300" }; -+ const on = try parse(a, &(rule ++ .{ "--silence-redraw-budget-s", "30" }), &u); -+ try std.testing.expectEqualDeep(@import("speech_register.zig").Rule{ .st = 6, .high = 50, .high_hz = 300 }, on.redraw.register); -+ try std.testing.expectEqual(@as(?f64, 30), on.redraw.budget_s); -+ for ([_][]const []const u8{ -+ &.{ "m", "--register-redraw-st", "6" }, -+ &.{ "m", "--register-redraw-high", "50", "--register-high-hz", "300" }, -+ rule[0..4], -+ &(rule ++ .{ "--register-redraw-st", "0" }), -+ &(rule ++ .{ "--register-high-hz", "700" }), -+ &(rule ++ .{ "--silence-redraw-controls", "sighs" }), -+ }) |bad| try std.testing.expectError(error.Usage, parse(a, bad, &u)); -+ var out: std.Io.Writer.Allocating = .init(a); -+ try capabilities(&out.writer, .{ .version = "test" }); -+ for ([_][]const u8{ "--register-redraw-st", "--register-redraw-high", "--register-high-hz" }) |flag| -+ try std.testing.expect(std.mem.indexOf(u8, out.written(), try std.fmt.allocPrint(a, "\"{s}\": {{}}", .{flag})) != null); -+} -+ - test "parse and capabilities share the table" { - var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer arena.deinit(); -diff --git a/zig/src/server/main.zig b/zig/src/server/main.zig -index ac6dd791..ea254ab9 100644 ---- a/zig/src/server/main.zig -+++ b/zig/src/server/main.zig -@@ -31,6 +31,7 @@ const usage_line = "usage: tensorfold serve [-h] [--host HOST] [--port PORT] [-- - " [--silence-redraws N]" ++ - " [--silence-redraw-budget-s SECONDS] [--silence-redraw-controls LIST]" ++ - " [--short-reply-ratio R] [--short-reply-chars-per-s CPS]" ++ -+ " [--register-redraw-st ST] [--register-redraw-high PERCENT] [--register-high-hz HZ]" ++ - " [--compact-at COMPACT_AT] [--compact-keep COMPACT_KEEP]" ++ - " [--compact-memory COMPACT_MEMORY] [--slide] [--slide-graph SLIDE_GRAPH] [--parallel PARALLEL]" ++ - " [--cortheon] [--no-update-check] [--backend {auto,mlx,cuda}] [--device DEVICE]" ++ -diff --git a/zig/src/server/root.zig b/zig/src/server/root.zig -index 95732579..3ff11101 100644 ---- a/zig/src/server/root.zig -+++ b/zig/src/server/root.zig -@@ -45,6 +45,7 @@ test { - _ = @import("speech_stream_test.zig"); - _ = @import("speech_redraw.zig"); - _ = @import("speech_redraw_test.zig"); -+ _ = @import("speech_register_test.zig"); - _ = @import("drama_prompt.zig"); - _ = @import("health_test.zig"); - _ = @import("memory_routes.zig"); -diff --git a/zig/src/server/serve.zig b/zig/src/server/serve.zig -index 93570c9e..2ab49ef2 100644 ---- a/zig/src/server/serve.zig -+++ b/zig/src/server/serve.zig -@@ -165,6 +165,7 @@ pub fn run(gpa: Allocator, io: std.Io, args: cli.Args, s: Setup) u8 { - .vision_urls = args.vision_urls, - .seed_salt = salt, - .silence_mid = args.silence.mid_seconds != null, -+ .silence_guard = args.silence.enabled(), - .prompt_seed = args.prompt_seed, - .sse_keepalive_s = keepalive_s, - .request_log = env(s, "TENSORFOLD_REQUEST_LOG"), -diff --git a/zig/src/server/server.zig b/zig/src/server/server.zig -index 4b1e0955..745c7816 100644 ---- a/zig/src/server/server.zig -+++ b/zig/src/server/server.zig -@@ -58,6 +58,7 @@ pub const Config = struct { - vision_urls: bool = false, - seed_salt: i64 = 0, - silence_mid: bool = false, -+ silence_guard: bool = false, - /// --prompt-seed keeps historical prompt-keyed sampling when a request omits its seed. - prompt_seed: bool = false, - /// TENSORFOLD_REQUEST_LOG: where chat and completion bodies are appended; null: nowhere. -diff --git a/zig/src/server/speech.zig b/zig/src/server/speech.zig -index e40df84f..c9002e0c 100644 ---- a/zig/src/server/speech.zig -+++ b/zig/src/server/speech.zig -@@ -10,6 +10,7 @@ const pyrepr = @import("pyrepr.zig"); - const reply_text = @import("reply_text.zig"); - const drama_prompt = @import("drama_prompt.zig"); - const speech_redraw = @import("speech_redraw.zig"); -+const speech_register = @import("speech_register_log.zig"); - const ids = @import("ids.zig"); - const log = @import("log.zig"); - const clock = @import("clock.zig"); -@@ -43,20 +44,9 @@ pub const Format = enum { - - const Plan = struct { input: []const u8, prompt: []const u32, frames: ?[]const [11]i32 = null, refs: []const @import("drama_prompt").Reference = &.{}, seed: u64, format: Format, stream: bool, budget_s: ?f64 = null }; - --/// One whole take: its audio and why it ended. --const Take = struct { pcm: []f32, reason: api.Reason }; -+const Take = struct { pcm: []f32, reason: api.Reason, trip: bool = false, jump: ?f64 = null }; - --const Metadata = struct { -- enabled: bool, -- redraws: u8 = 0, -- mid: ?u8 = null, -- -- fn add(m: Metadata, c: *Conn) !void { -- if (!m.enabled) return; -- var buf: [3]u8 = undefined; -- try c.addHeader("X-Silence-Redraws", std.fmt.bufPrint(&buf, "{d}", .{m.redraws}) catch unreachable); -- } --}; -+const Metadata = @import("speech_metadata.zig").Metadata; - - pub fn post(srv: *Server, c: *Conn, a: Allocator) !void { - const sp = srv.info.speech orelse { -@@ -67,12 +57,12 @@ pub fn post(srv: *Server, c: *Conn, a: Allocator) !void { - var cx: Cx = .{ .a = a }; - const id = try ids.make(a, "speech-", 32); - const received = clock.nowNs(srv.io); -- var metadata: Metadata = .{ .enabled = srv.config.redraw.count() > 0, .mid = if (srv.config.silence_mid) 0 else null }; -+ var metadata: Metadata = .{ .enabled = srv.config.redraw.count() > 0, .mid = if (srv.config.silence_mid) 0 else null, .register_on = srv.config.redraw.register.on() }; - const raw = http_body.readJson(c, &cx) catch |e| return refuse(c, &cx, id, metadata, e); - var plan = prepare(srv, &cx, sp, raw) catch |e| return refuse(c, &cx, id, metadata, e); - // only chunked framing tells a client that a stream which ends early is incomplete - if (plan.stream and !std.mem.eql(u8, c.version, "HTTP/1.1")) return fail(c, &cx, id, metadata, .request, "stream=true requires HTTP/1.1"); -- if (srv.config.redraw.count() > 0) -+ if (srv.config.redraw.count() > 0 or srv.config.redraw.register.on()) - plan.budget_s = speech_redraw.budget(srv.config.redraw.budget_s, c.header("X-Deadline-S")) catch - return fail(c, &cx, id, metadata, .request, "X-Deadline-S must be a positive number of seconds"); - synthesize(srv, c, &cx, id, sp, plan, received, &metadata); -@@ -214,55 +204,79 @@ fn synthesize(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInf - if (metadata.mid) |*trips| trips.* +|= @intFromBool(result.silence_mid); - if (why(&box, result.samples, pcm.len)) |message| return fail(c, cx, id, metadata.*, .server, message); - const take: Take = .{ .pcm = pcm[0..result.samples], .reason = box.reason }; -- if (srv.config.redraw.count() == 0 or !srv.config.redraw.trips(take.reason, take.pcm.len, sp.sample_rate, plan.input)) return reply(srv, c, cx, id, sp, plan, received, take, metadata.*); -- const spare = srv.gpa.alloc(f32, pcm.len) catch return reply(srv, c, cx, id, sp, plan, received, unused(id, 1, take, "out of memory"), metadata.*); -+ var spare: []f32 = &.{}; - defer srv.gpa.free(spare); -- const sent = redraw(srv, c, cx, id, sp, plan, received, request, started, take, spare, metadata) orelse return; -+ const sent = redraw(srv, c, cx, id, sp, plan, received, request, started, take, &spare, metadata) orelse return; - reply(srv, c, cx, id, sp, plan, received, sent, metadata.*); - } - --/// Redraws a tripped take as seed salt + k while redraws and the budget last; the last take with audio, null if left. --fn redraw(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, plan: Plan, received: i96, first: api.SpeechRequest, started: i96, tripped: Take, spare: []f32, metadata: *Metadata) ?Take { -+fn redraw(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, plan: Plan, received: i96, first: api.SpeechRequest, started: i96, drawn: Take, spare: *[]f32, metadata: *Metadata) ?Take { -+ const policy = srv.config.redraw; - var next = first; -+ var take = drawn; - var input = plan.input; -- if (srv.config.redraw.controls) |controls| { -- input = @import("speech_plain.zig").filter(cx.a, controls, plan.input) catch return unused(id, 1, tripped, "out of memory"); -- if (input.ptr != plan.input.ptr) { -- const encoded = promptOf(srv, cx, sp, input, plan.refs) catch |e| return unused(id, 1, tripped, if (e == error.OutOfMemory) "out of memory" else cx.message); -- next.prompt = encoded.prompt; -- next.frames = encoded.frames orelse (rows(cx.a, next.prompt) catch |e| return unused(id, 1, tripped, @errorName(e))); -- } -- } -- const buffers = [2][]f32{ first.pcm, spare }; -- var live: usize = 0; -- var take = tripped; -+ var held: ?Take = null; - var start = started; -- for (1..@as(usize, srv.config.redraw.count()) + 1) |k| { -+ var k: usize = 1; -+ while (true) : (k += 1) { -+ take.trip = policy.count() > 0 and policy.trips(take.reason, take.pcm.len, sp.sample_rate, input); -+ const fallback = keep(take, held, srv.config.silence_guard); -+ if (take.trip) { -+ if (metadata.redraws == policy.count()) break; -+ if (metadata.redraws == 0) if (policy.controls) |controls| { -+ input = @import("speech_plain.zig").filter(cx.a, controls, plan.input) catch return unused(id, k, fallback, "out of memory"); -+ if (input.ptr != plan.input.ptr) { -+ const encoded = promptOf(srv, cx, sp, input, plan.refs) catch |e| return unused(id, k, fallback, if (e == error.OutOfMemory) "out of memory" else cx.message); -+ next.prompt = encoded.prompt; -+ next.frames = encoded.frames orelse (rows(cx.a, next.prompt) catch |e| return unused(id, k, fallback, @errorName(e))); -+ } -+ }; -+ } else { -+ if (!policy.register.on()) break; -+ take.jump = speech_register.jump(cx.a, id, take.pcm, sp.sample_rate, policy.register); -+ if ((take.jump orelse 0) == 0) break; -+ metadata.register_tripped = true; -+ log.line("speech {s} register jump={d:.2}st", .{ id, take.jump.? }); -+ if (metadata.register_redrawn) break; -+ held = take; -+ } - const now = clock.nowNs(srv.io); -- if (!speech_redraw.fits(plan.budget_s, received, start, now)) return unused(id, k, take, "t + d exceeds the budget"); -+ if (!speech_redraw.fits(plan.budget_s, received, start, now)) return unused(id, k, fallback, "t + d exceeds the budget"); - const start_by = if (plan.budget_s) |s| s - clock.seconds(now - start) else null; - start = now; -+ if (spare.len == 0) spare.* = srv.gpa.alloc(f32, first.pcm.len) catch return unused(id, k, fallback, "out of memory"); - var result: api.SpeechResult = .{}; -- next.pcm = buffers[1 - live]; -+ // Once register holds a take, subsequent silence retries must never overwrite it. -+ const preserved = if (held) |h| h.pcm.ptr else take.pcm.ptr; -+ next.pcm = if (preserved == first.pcm.ptr) spare.* else first.pcm; - next.result = &result; - next.seed = api.seedFor(next.prompt, srv.config.seed_salt +% @as(i64, @intCast(k))); - var box: Mailbox = .{ .io = srv.io, .gpa = srv.gpa }; - defer box.deinit(); - const engine_id = srv.next_id.fetchAdd(1, .monotonic); -- submitRedraw(srv, c, engine_id, &next, &box, received, start_by) catch |e| return unused(id, k, take, @errorName(e)); -- metadata.redraws += 1; -+ submitRedraw(srv, c, engine_id, &next, &box, received, start_by) catch |e| return unused(id, k, fallback, @errorName(e)); -+ if (take.trip) metadata.redraws += 1 else metadata.register_redrawn = true; - if (settle(srv, c, &box, engine_id, null) == null) { - left(srv, c, id, plan.prompt.len, received); - return null; - } - if (metadata.mid) |*trips| trips.* +|= @intFromBool(result.silence_mid); -- if (why(&box, result.samples, next.pcm.len)) |message| return unused(id, k, take, message); -- live = 1 - live; -- take = .{ .pcm = buffers[live][0..result.samples], .reason = box.reason }; -+ if (why(&box, result.samples, next.pcm.len)) |message| return unused(id, k, fallback, message); -+ take = .{ .pcm = next.pcm[0..result.samples], .reason = box.reason }; - log.line("speech {s} redraw {d} finish={s}", .{ id, k, @tagName(take.reason) }); -- if (!srv.config.redraw.trips(take.reason, take.pcm.len, sp.sample_rate, input)) break; - } -- return take; -+ const sent = keep(take, held, srv.config.silence_guard); -+ if (held != null and sent.pcm.ptr == held.?.pcm.ptr) -+ return unused(id, k - 1, sent, if (take.jump == null) "unmeasured" else "it ends worse or jumps no less"); -+ return sent; -+} -+ -+fn keep(later: Take, held: ?Take, guard: bool) Take { -+ const first = held orelse return later; -+ if (later.pcm.ptr == first.pcm.ptr) return first; -+ const better = !later.trip and later.reason != .silence and -+ (if (later.jump) |value| value < first.jump.? else guard); -+ return if (better) later else first; - } - - fn submitRedraw(srv: *Server, c: *Conn, id: api.Id, request: *const api.SpeechRequest, box: *Mailbox, received: i96, start_by: ?f64) api.SpeechSubmitError!void { -@@ -451,11 +465,7 @@ fn sendAudio(c: *Conn, format: Format, sample_rate: u32, data: []const u8, reaso - var len: [24]u8 = undefined; - try c.addHeader("Content-Length", std.fmt.bufPrint(&len, "{d}", .{header_len + data.len}) catch unreachable); - try c.addHeader("X-Finish-Reason", @tagName(reason)); -- try metadata.add(c); -- if (metadata.mid) |trips| { -- var count: [3]u8 = undefined; -- try c.addHeader("X-Silence-Mid-Trips", std.fmt.bufPrint(&count, "{d}", .{trips}) catch unreachable); -- } -+ try metadata.addWhole(c); - if (c.close) try c.addHeader("Connection", "close"); - defer c.out.clearRetainingCapacity(); - if (!std.mem.eql(u8, c.version, "HTTP/0.9")) try c.out.appendSlice(c.gpa, "\r\n"); -@@ -574,16 +584,17 @@ test "allowed controls filters before speech input validation" { - var srv: Server = undefined; - srv.config = .{ .served_name = "m", .model_ids = &.{"m"}, .allowed_controls = try @import("drama_allow.zig").parse("") }; - const sp: api.SpeechInfo = .{ .sample_rate = 44100, .frame_length = 2048, .max_frames = 1024, .max_prompt_tokens = 4096 }; -- const long = try a.alloc(u8, max_input_chars + 1); -- @memset(long, 'x'); -- const body = (try json.parse(a, try std.fmt.allocPrint(a, "{{\"model\":\"m\",\"input\":\"[{s}]\"}}", .{long}))).ok; -+ const body = (try json.parse(a, try std.fmt.allocPrint(a, "{{\"model\":\"m\",\"input\":\"[{s}]\"}}", .{&@as([max_input_chars + 1]u8, @splat('x'))}))).ok; - var cx: Cx = .{ .a = a }; - try std.testing.expectError(error.Refused, prepare(&srv, &cx, sp, body)); - try std.testing.expectEqualStrings("input must be at most 4096 characters", cx.message); -- const controls = (try json.parse(a, "{\"model\":\"m\",\"input\":\"[sighs]\"}")).ok; -- try std.testing.expectError(error.Refused, prepare(&srv, &cx, sp, controls)); -+ try std.testing.expectError(error.Refused, prepare(&srv, &cx, sp, (try json.parse(a, "{\"model\":\"m\",\"input\":\"[sighs]\"}")).ok)); - try std.testing.expectEqualStrings("input must be a non-empty string", cx.message); - srv.config.allowed_controls = null; - try std.testing.expectError(error.Refused, prepare(&srv, &cx, sp, body)); - try std.testing.expectEqualStrings("input must be at most 4096 characters", cx.message); - } -+ -+pub const Test = if (@import("builtin").is_test) struct { -+ pub const redrawTake = redraw; -+} else struct {}; -diff --git a/zig/src/server/speech_metadata.zig b/zig/src/server/speech_metadata.zig -new file mode 100644 -index 00000000..fa357fb5 ---- /dev/null -+++ b/zig/src/server/speech_metadata.zig -@@ -0,0 +1,29 @@ -+const std = @import("std"); -+const Conn = @import("http_conn.zig").Conn; -+ -+pub const Metadata = struct { -+ enabled: bool, -+ redraws: u8 = 0, -+ mid: ?u8 = null, -+ register_on: bool = false, -+ register_tripped: bool = false, -+ register_redrawn: bool = false, -+ -+ pub fn add(m: Metadata, c: *Conn) !void { -+ if (!m.enabled) return; -+ var buf: [3]u8 = undefined; -+ try c.addHeader("X-Silence-Redraws", std.fmt.bufPrint(&buf, "{d}", .{m.redraws}) catch unreachable); -+ } -+ -+ pub fn addWhole(m: Metadata, c: *Conn) !void { -+ try m.add(c); -+ if (m.mid) |trips| { -+ var buf: [3]u8 = undefined; -+ try c.addHeader("X-Silence-Mid-Trips", std.fmt.bufPrint(&buf, "{d}", .{trips}) catch unreachable); -+ } -+ if (m.register_on) { -+ try c.addHeader("X-Register-Trip", if (m.register_tripped) "1" else "0"); -+ try c.addHeader("X-Register-Redraws", if (m.register_redrawn) "1" else "0"); -+ } -+ } -+}; -diff --git a/zig/src/server/speech_redraw.zig b/zig/src/server/speech_redraw.zig -index 1496e67b..62a7f294 100644 ---- a/zig/src/server/speech_redraw.zig -+++ b/zig/src/server/speech_redraw.zig -@@ -1,4 +1,4 @@ --//! Opt-in redraws of whole speech replies that the silence guard ended or that stopped short of their input. -+//! Opt-in redraws of whole speech replies tripped by silence, short duration or register. - const std = @import("std"); - const api = @import("engine_api"); - const clock = @import("clock.zig"); -@@ -15,6 +15,8 @@ pub const Policy = struct { - short_ratio: ?f64 = null, - /// Input characters per second of audio in complete replies of the served voice; the expected length's rate. - short_chars_per_s: ?f64 = null, -+ /// The register guard's thresholds: off unless all are set. -+ register: @import("speech_register.zig").Rule = .{}, - - pub fn count(p: Policy) u8 { - return p.max orelse 0; -@@ -26,13 +28,14 @@ pub const Policy = struct { - if (p.short_chars_per_s) |c| if (!std.math.isFinite(c) or c <= 0) return error.BadShortReplyRate; - if (p.short_ratio != null and p.short_chars_per_s == null) return error.MissingShortReplyRate; - if (p.short_chars_per_s != null and p.short_ratio == null) return error.MissingShortReplyRatio; -- if (p.count() == 0 and (p.budget_s != null or p.controls != null or p.short_ratio != null)) return error.MissingRedrawCount; -+ try p.register.validate(); -+ if (p.count() == 0 and (p.controls != null or p.short_ratio != null or p.budget_s != null and !p.register.on())) return error.MissingRedrawCount; - if (p.count() > 0 and !silence.enabled()) return error.MissingSilenceGuard; - } - - pub fn refusal(p: Policy, model_type: []const u8) ?[]const u8 { -- if (p.max != null and !std.mem.eql(u8, model_type, "fish_omni")) -- return "--silence-redraws and its options are for Drama speech: serve without them"; -+ if ((p.max != null or p.register.on()) and !std.mem.eql(u8, model_type, "fish_omni")) -+ return "--silence-redraws, the register flags and their options are for Drama speech: serve without them"; - return null; - } - -@@ -66,9 +69,12 @@ pub fn fits(budget_s: ?f64, received: i96, started: i96, now: i96) bool { - return clock.seconds(now - received) + clock.seconds(now - started) <= limit; - } - --test "redraw policy is off by default and needs a count and an enabled guard" { -+test "redraw policy is off by default; redraws need the guard, a budget redraws or the register guard" { - const guard: api.speech_guard.Config = .{ .floor_dbfs = -60, .seconds = 1 }; -+ const register: @import("speech_register.zig").Rule = .{ .st = 6, .high = 50, .high_hz = 300 }; - try (Policy{}).validate(.{}); -+ try (Policy{ .budget_s = 30, .register = register }).validate(.{}); -+ try std.testing.expectError(error.MissingRedrawCount, (Policy{ .controls = .{}, .register = register }).validate(guard)); - try (Policy{ .max = 2, .budget_s = 30, .controls = .{}, .short_ratio = 0.25, .short_chars_per_s = 10 }).validate(guard); - try std.testing.expectError(error.MissingSilenceGuard, (Policy{ .max = 1 }).validate(.{})); - try std.testing.expectError(error.MissingRedrawCount, (Policy{ .budget_s = 30 }).validate(guard)); -@@ -103,8 +109,8 @@ test "a stop of enough characters trips only under the ratio of its expected len - try std.testing.expect(off.trips(.silence, 1, 1000, input)); - } - --test "an explicit redraw count, even 0, is refused on text and accepted on Drama speech" { -- for ([_]Policy{ .{ .max = 0 }, .{ .max = 2 } }) |p| { -+test "an explicit redraw count, even 0, or the register guard is refused on text and accepted on Drama speech" { -+ for ([_]Policy{ .{ .max = 0 }, .{ .max = 2 }, .{ .register = .{ .st = 6, .high = 50, .high_hz = 300 } } }) |p| { - try std.testing.expect(p.refusal("nemotron_h") != null); - try std.testing.expect(p.refusal("fish_omni") == null); - } -diff --git a/zig/src/server/speech_redraw_test.zig b/zig/src/server/speech_redraw_test.zig -index d8057a17..50c2c83c 100644 ---- a/zig/src/server/speech_redraw_test.zig -+++ b/zig/src/server/speech_redraw_test.zig -@@ -14,7 +14,7 @@ const expectHeader = harness.expectHeader; - const salt = 5; - - fn start(s: *Served, script: *Script, redraw: Policy, headers: []const u8, body: []const u8) !void { -- try s.open(script, .{ .served_name = "m", .model_ids = &.{"m"}, .seed_salt = salt, .redraw = redraw }, headers, "HTTP/1.1", body, 2000); -+ try s.open(script, .{ .served_name = "m", .model_ids = &.{"m"}, .seed_salt = salt, .redraw = redraw, .silence_mid = script.mid_enabled }, headers, "HTTP/1.1", body, 2000); - } - - fn whole(comptime input: []const u8) []const u8 { -@@ -117,7 +117,7 @@ test "a malformed X-Deadline-S is refused when redraws are on" { - test "redraw controls filter the redrawn input; an input with nothing left is not redrawn" { - var script: Script = .{ .steps = &.{frame}, .reasons = &.{ .silence, .stop }, .inline_ = true }; - try expectReply(&script, .{ .max = 1, .controls = try drama_allow.parse("sighs") }, "", "hi [sighs] [laughs] there", "X-Finish-Reason: stop\r\n", 1, 2); -- try std.testing.expectEqual(try seed("hi [sighs] there", 1), script.seeds[1]); -+ try std.testing.expectEqual(try seed("hi [sighs] there", 1), script.seeds[1]); - var empty: Script = .{ .steps = &.{frame}, .reasons = &.{ .silence, .stop }, .inline_ = true }; - try expectReply(&empty, .{ .max = 1, .controls = try drama_allow.parse("") }, "", "[sighs]", "X-Finish-Reason: silence\r\n", 0, 1); - } -@@ -228,3 +228,35 @@ test "a busy redraw stops at its latest start without counting an unsubmitted ta - try expectReply(&script, .{ .max = 1 }, "X-Deadline-S: 0.1\r\n", "hi", "X-Finish-Reason: silence\r\n", 0, 1); - try std.testing.expect(script.busy_replies > 1); - } -+ -+test "mid metadata counts every completed take rather than only the selected reply" { -+ for ([_]bool{ false, true }) |enabled| { -+ var script: Script = .{ -+ .steps = &.{frame}, -+ .reasons = &.{ .silence, .silence, .stop }, -+ .mid_takes = &.{ true, true, false }, -+ .mid_enabled = enabled, -+ .inline_ = true, -+ }; -+ var s: Served = undefined; -+ try start(&s, &script, .{ .max = 2 }, "", whole("hi")); -+ defer s.deinit(); -+ const reply = try finish(&s, &script); -+ try expectHeader(reply.head, "X-Finish-Reason: stop\r\nX-Silence-Redraws: 2\r\n"); -+ if (enabled) { -+ try expectHeader(reply.head, "X-Silence-Mid-Trips: 2\r\n"); -+ } else try std.testing.expect(std.mem.indexOf(u8, reply.head, "X-Silence-Mid-Trips") == null); -+ try expectTake(reply.body, 2); -+ } -+} -+ -+test "mid metadata is absent on error replies even when configured" { -+ var script: Script = .{ .steps = &.{frame}, .mid_enabled = true, .inline_ = true }; -+ var s: Served = undefined; -+ try start(&s, &script, .{ .max = 1 }, "", "{\"model\":\"m\"}"); -+ defer s.deinit(); -+ const reply = try finish(&s, &script); -+ try expectHeader(reply.head, "HTTP/1.1 400"); -+ try expectHeader(reply.head, "X-Silence-Redraws: 0\r\n"); -+ try std.testing.expect(std.mem.indexOf(u8, reply.head, "X-Silence-Mid-Trips") == null); -+} -diff --git a/zig/src/server/speech_register.zig b/zig/src/server/speech_register.zig -new file mode 100644 -index 00000000..6dbcb456 ---- /dev/null -+++ b/zig/src/server/speech_register.zig -@@ -0,0 +1,157 @@ -+//! The register guard: whether a phrase after the first opens far above the reply's other phrases in pitch. -+const std = @import("std"); -+const Allocator = std.mem.Allocator; -+ -+/// Uses Praat's published To Pitch (ac) parameter defaults, not its analysis; measured values can differ. -+const floor_hz = 75; -+const ceiling_hz = 600; -+const voicing = 0.45; -+const silence = 0.03; -+const octave_cost = 0.01; -+/// Pitch is read every 10 ms over windows of two frames, the audio averaged down to 8 to 16 kHz. -+const frames_per_s = 100; -+const min_rate = 8000; -+/// Unvoiced runs of 0.25 s part phrases; an opening is the median pitch of a phrase's first 0.25 s voiced. -+const rest_frames = 25; -+const open_frames = 25; -+ -+/// A phrase after the first trips at `st` semitones over the others' median opening with `high`% of it over `high_hz`. -+pub const Rule = struct { -+ st: ?f64 = null, -+ high: ?f64 = null, -+ high_hz: ?f64 = null, -+ -+ pub fn on(r: Rule) bool { -+ return r.st != null; -+ } -+ -+ pub fn validate(r: Rule) !void { -+ if (r.st == null and r.high == null and r.high_hz == null) return; -+ const st = r.st orelse return error.MissingRegisterThreshold; -+ const high = r.high orelse return error.MissingRegisterThreshold; -+ const hz = r.high_hz orelse return error.MissingRegisterThreshold; -+ if (!(std.math.isFinite(st) and st > 0 and high > 0 and high <= 100 and hz >= floor_hz and hz <= ceiling_hz)) -+ return error.BadRegisterThreshold; -+ } -+}; -+ -+const Phrase = struct { opening: f32, high: f64 }; -+ -+/// Largest qualifying excess in semitones; zero for measured non-trips, null when no comparison can be measured. -+pub fn jump(a: Allocator, pcm: []const f32, sample_rate: u32, rule: Rule) error{ OutOfMemory, NonFinite }!?f64 { -+ var peak: f32 = 0; -+ for (pcm) |x| { -+ if (!std.math.isFinite(x)) return error.NonFinite; -+ peak = @max(peak, @abs(x)); -+ } -+ if (sample_rate < min_rate or peak == 0) return null; -+ const step = sample_rate / min_rate; -+ const rate = @as(f32, @floatFromInt(sample_rate)) / @as(f32, @floatFromInt(step)); -+ const hop: usize = @intFromFloat(rate / frames_per_s); -+ const span = 2 * hop + @as(usize, @intFromFloat(rate / floor_hz)) + 2; -+ const high_hz: f32 = @floatCast(rule.high_hz.?); -+ var phrases: std.ArrayList(Phrase) = .empty; -+ var run: Run = .{}; -+ var quiet: usize = 0; -+ var x: [4 * min_rate / frames_per_s + 2 * min_rate / floor_hz + 2]f32 = undefined; -+ var at: usize = 0; -+ while ((at + span) * step <= pcm.len) : (at += hop) { -+ for (x[0..span], 0..) |*v, i| { -+ var sum: f32 = 0; -+ for (pcm[(at + i) * step ..][0..step]) |s| sum += s; -+ v.* = sum / @as(f32, @floatFromInt(step)); -+ } -+ const f0 = pitch(x[0..span], 2 * hop, rate, silence * peak) orelse { -+ quiet += 1; -+ if (quiet == rest_frames) try run.close(a, &phrases); -+ continue; -+ }; -+ quiet = 0; -+ if (run.voiced < open_frames) run.head[run.voiced] = f0; -+ run.voiced += 1; -+ run.high += @intFromBool(f0 > high_hz); -+ } -+ try run.close(a, &phrases); -+ const list = phrases.items; -+ if (list.len < 2) return null; -+ const others = try a.alloc(f32, list.len - 1); -+ var most: f64 = 0; -+ for (list[1..], 1..) |p, i| { -+ if (p.high < rule.high.?) continue; -+ var n: usize = 0; -+ for (list, 0..) |q, j| if (j != i) { -+ others[n] = q.opening; -+ n += 1; -+ }; -+ const excess = 12 * std.math.log2(@as(f64, p.opening / median(others))); -+ if (excess >= rule.st.?) most = @max(most, excess); -+ } -+ return most; -+} -+ -+/// The phrase being read: its first voiced pitches, and its voiced and high frame counts. -+const Run = struct { -+ head: [open_frames]f32 = undefined, -+ voiced: usize = 0, -+ high: usize = 0, -+ -+ /// Ends the run, kept as a phrase when it voiced a whole opening. -+ fn close(r: *Run, a: Allocator, phrases: *std.ArrayList(Phrase)) Allocator.Error!void { -+ defer r.* = .{}; -+ if (r.voiced < open_frames) return; -+ const share = 100 * @as(f64, @floatFromInt(r.high)) / @as(f64, @floatFromInt(r.voiced)); -+ try phrases.append(a, .{ .opening = median(&r.head), .high = share }); -+ } -+}; -+ -+/// Sorts `v` and returns its median. -+fn median(v: []f32) f32 { -+ std.mem.sort(f32, v, {}, std.sort.asc(f32)); -+ return (v[(v.len - 1) / 2] + v[v.len / 2]) / 2; -+} -+ -+/// The pitch of `x` at `rate`, cross-correlating its first `width` samples; null when unvoiced or under `quiet`. -+fn pitch(x: []const f32, width: usize, rate: f32, quiet: f32) ?f32 { -+ const head = x[0..width]; -+ var top: f32 = 0; -+ for (head) |v| top = @max(top, @abs(v)); -+ if (top < quiet) return null; -+ const lo: usize = @intFromFloat(rate / ceiling_hz); -+ const hi: usize = @intFromFloat(rate / floor_hz); -+ const energy = dot(head, head); -+ var r: [2 * min_rate / floor_hz + 2]f32 = undefined; -+ for (lo - 1..hi + 2) |lag| { -+ const shifted = x[lag..][0..head.len]; -+ const e = dot(shifted, shifted); -+ r[lag] = if (e > 0) dot(head, shifted) / @sqrt(energy * e) else 0; -+ } -+ // each peak at its parabola's vertex, the higher pitch favoured by the octave cost -+ var period: f32 = 0; -+ var height: f32 = 0; -+ var score = -std.math.inf(f32); -+ for (lo..hi + 1) |lag| { -+ const before, const at, const after = .{ r[lag - 1], r[lag], r[lag + 1] }; -+ if (at < before or at < after) continue; -+ const bend = before - 2 * at + after; -+ const shift = if (bend < 0) 0.5 * (before - after) / bend else 0; -+ const peak = at - 0.25 * (before - after) * shift; -+ const lags = @as(f32, @floatFromInt(lag)) + shift; -+ const s = peak - octave_cost * std.math.log2(lags); -+ if (s > score) { -+ score = s; -+ period = lags; -+ height = peak; -+ } -+ } -+ return if (height < voicing) null else rate / period; -+} -+ -+fn dot(u: []const f32, v: []const f32) f32 { -+ const V = @Vector(8, f32); -+ var acc: V = @splat(0); -+ var i: usize = 0; -+ while (i + 8 <= u.len) : (i += 8) acc += @as(V, u[i..][0..8].*) * @as(V, v[i..][0..8].*); -+ var sum = @reduce(.Add, acc); -+ for (u[i..], v[i..]) |p, q| sum += p * q; -+ return sum; -+} -diff --git a/zig/src/server/speech_register_log.zig b/zig/src/server/speech_register_log.zig -new file mode 100644 -index 00000000..6d6e404c ---- /dev/null -+++ b/zig/src/server/speech_register_log.zig -@@ -0,0 +1,16 @@ -+const std = @import("std"); -+const register = @import("speech_register.zig"); -+const log = @import("log.zig"); -+ -+pub fn jump(a: std.mem.Allocator, id: []const u8, pcm: []const f32, sample_rate: u32, rule: register.Rule) ?f64 { -+ const measured = register.jump(a, pcm, sample_rate, rule) catch |e| { -+ log.line("speech {s} register unmeasured: {s}", .{ id, @errorName(e) }); -+ return null; -+ }; -+ if (measured == null) log.line("speech {s} register unmeasured: {s}", .{ id, "no voiced phrase comparison" }); -+ return measured; -+} -+ -+test "register errors and absent measurements leave the take unmeasured and log the request" { -+ try @import("speech_unmeasured_test.zig").check(@import("speech.zig").Test.redrawTake); -+} -diff --git a/zig/src/server/speech_register_test.zig b/zig/src/server/speech_register_test.zig -new file mode 100644 -index 00000000..38018541 ---- /dev/null -+++ b/zig/src/server/speech_register_test.zig -@@ -0,0 +1,288 @@ -+//! The register guard on synthetic tones: the measure alone, then whole replies against the scripted engine. -+const std = @import("std"); -+const api = @import("engine_api"); -+const harness = @import("speech_stream_test.zig"); -+const drama_prompt = @import("drama_prompt.zig"); -+const quantizeInPlace = @import("speech.zig").quantizeInPlace; -+const Policy = @import("speech_redraw.zig").Policy; -+const Rule = @import("speech_register.zig").Rule; -+const jump = @import("speech_register.zig").jump; -+const Allocator = std.mem.Allocator; -+ -+/// Seconds, then the pitch a part glides from and to; 0 Hz is a rest. -+const Part = struct { f64, f64, f64 }; -+const rest: Part = .{ 0.3, 0, 0 }; -+const rule: Rule = .{ .st = 5, .high = 30, .high_hz = 250 }; -+ -+/// Four falling phrases near 125 Hz, the first opening at `first` Hz and the third at `third`. -+fn reply(first: f64, third: f64) [7]Part { -+ return .{ .{ 0.6, first, first * 0.88 }, rest, .{ 0.6, 125, 110 }, rest, .{ 0.6, third, third * 0.85 }, rest, .{ 0.6, 128, 112 } }; -+} -+ -+/// The parts at `rate`: three harmonics of each glide, its phase the integral of its frequency. -+fn tones(a: Allocator, rate: f64, parts: []const Part) ![]f32 { -+ var out: std.ArrayList(f32) = .empty; -+ for (parts) |part| { -+ const seconds, const from, const to = part; -+ for (0..@intFromFloat(seconds * rate)) |i| { -+ const t = @as(f64, @floatFromInt(i)) / rate; -+ const phase = 2 * std.math.pi * (from * t + (to - from) * t * t / (2 * seconds)); -+ try out.append(a, @floatCast(0.3 * (@sin(phase) + @sin(2 * phase) / 2 + @sin(3 * phase) / 3))); -+ } -+ } -+ return out.items; -+} -+ -+fn measure(a: Allocator, parts: []const Part) !?f64 { -+ return jump(a, try tones(a, 44100, parts), 44100, rule); -+} -+ -+test "the register rule is off unless every threshold is set, each in range" { -+ try std.testing.expect(!(Rule{}).on() and rule.on()); -+ try (Rule{}).validate(); -+ try rule.validate(); -+ for ([_]Rule{ .{ .st = 5 }, .{ .high = 30, .high_hz = 250 }, .{ .st = 5, .high = 30 } }) |r| -+ try std.testing.expectError(error.MissingRegisterThreshold, r.validate()); -+ for ([_]f64{ 0, -1, std.math.inf(f64), std.math.nan(f64) }) |bad| { -+ try std.testing.expectError(error.BadRegisterThreshold, (Rule{ .st = bad, .high = 30, .high_hz = 250 }).validate()); -+ try std.testing.expectError(error.BadRegisterThreshold, (Rule{ .st = 5, .high = bad, .high_hz = 250 }).validate()); -+ } -+ for ([_]f64{ 74, 601, std.math.nan(f64) }) |bad| try std.testing.expectError(error.BadRegisterThreshold, (Rule{ .st = 5, .high = 30, .high_hz = bad }).validate()); -+ try std.testing.expectError(error.BadRegisterThreshold, (Rule{ .st = 5, .high = 101, .high_hz = 250 }).validate()); -+} -+ -+test "a phrase opening far above the others trips; a flat reply, a high first phrase or a jump under the line does not" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ // the third phrase opens near 319 Hz, 0.125 s into its glide; the others' openings have a median near 124.5 Hz -+ try std.testing.expectApproxEqAbs(12 * std.math.log2(319.0 / 124.5), (try measure(a, &reply(130, 330))).?, 0.3); -+ const flat = try tones(a, 44100, &reply(130, 127)); -+ try std.testing.expectEqual(@as(?f64, 0), try jump(a, flat, 44100, rule)); -+ try std.testing.expectEqual(@as(?f64, 0), try jump(a, flat, 44100, .{ .st = 5, .high = 30, .high_hz = 75 })); -+ try std.testing.expectEqual(@as(?f64, 0), try measure(a, &reply(330, 127))); -+ try std.testing.expectEqual(@as(?f64, 0), try measure(a, &reply(130, 220))); -+ // a high blip voiced for less than an opening is no phrase -+ try std.testing.expectEqual(@as(?f64, 0), try measure(a, &(comptime reply(130, 127) ++ [_]Part{ rest, .{ 0.2, 400, 400 } }))); -+ // a hum far under the peak leaves the rests silent -+ const hummed = try tones(a, 44100, &reply(130, 330)); -+ for (hummed, 0..) |*x, i| x.* += @floatCast(0.003 * @sin(2 * std.math.pi * 300 * @as(f64, @floatFromInt(i)) / 44100)); -+ try std.testing.expect((try jump(a, hummed, 44100, rule)).? > 0); -+} -+ -+test "steady phrases 3 and 7 semitones up read the larger jump across the pitch range and sample rates" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ for ([_]f64{ 16000, 24000, 44100 }) |rate| for ([_]f64{ 90, 200, 380 }) |hz| { -+ const up3 = hz * std.math.pow(f64, 2, 3.0 / 12.0); -+ const up7 = hz * std.math.pow(f64, 2, 7.0 / 12.0); -+ const parts = [_]Part{ .{ 0.6, hz, hz }, rest, .{ 0.6, up3, up3 }, rest, .{ 0.6, hz, hz }, rest, .{ 0.6, up7, up7 } }; -+ const got = try jump(a, try tones(a, rate, &parts), @intFromFloat(rate), .{ .st = 2, .high = 30, .high_hz = 75 }); -+ try std.testing.expectApproxEqAbs(7, got.?, 0.05); -+ }; -+} -+ -+test "non-finite audio is refused and audio under 8 kHz is not measured" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ try std.testing.expectError(error.NonFinite, jump(a, &.{ 0.1, std.math.nan(f32) }, 44100, rule)); -+ try std.testing.expectEqual(null, try jump(a, try tones(a, 4000, &reply(130, 330)), 4000, rule)); -+} -+ -+const salt = 5; -+ -+/// The seed stock speech gives "hi" under salt + `k`. -+fn seed(a: Allocator, k: i64) !u64 { -+ var bytes: harness.Bytes = .{}; -+ return api.seedFor(try drama_prompt.encode(a, bytes.text(), "hi"), salt + k); -+} -+ -+/// Serves `audio` as successive takes of one whole reply; the reply is take `sent`, after `takes` seeded salt + k. -+fn expectSent(a: Allocator, audio: []const []const f32, reasons: []const api.Reason, redraw: Policy, headers: []const u8, sent: usize, takes: usize) !void { -+ return expectSentWith(a, audio, reasons, redraw, headers, sent, takes, false, null); -+} -+ -+fn expectSentWith(a: Allocator, audio: []const []const f32, reasons: []const api.Reason, redraw: Policy, headers: []const u8, sent: usize, takes: usize, guard: bool, expected: ?[]const u8) !void { -+ const n = [_]usize{audio[0].len}; -+ var script: harness.Script = .{ .steps = &n, .audio = audio, .reasons = reasons, .max_frames = @intCast(n[0] / harness.frame + 1), .inline_ = true }; -+ var s: harness.Served = undefined; -+ try s.open(&script, .{ .served_name = "m", .model_ids = &.{"m"}, .seed_salt = salt, .redraw = redraw, .silence_guard = guard }, headers, "HTTP/1.1", "{\"model\":\"m\",\"input\":\"hi\",\"response_format\":\"pcm\"}", 2000); -+ defer s.deinit(); -+ const body = body: { -+ defer s.join(&script); -+ _ = try s.readHead(); -+ break :body try s.readRest(); -+ }; -+ try harness.expectHeader(s.got.items[0..s.read_at], "HTTP/1.1 200"); -+ if (expected) |value| try harness.expectHeader(s.got.items[0..s.read_at], value); -+ if (!redraw.register.on()) try std.testing.expect(std.mem.indexOf(u8, s.got.items[0..s.read_at], "X-Register-") == null); -+ try std.testing.expectEqualSlices(u8, try quantizeInPlace(try a.dupe(f32, audio[sent])), body); -+ try std.testing.expectEqual(takes, script.takes); -+ for (script.seeds[0..takes], 0..) |got, k| try std.testing.expectEqual(try seed(a, @intCast(k)), got); -+} -+ -+test "a high-onset take is redrawn once and the take with the smaller jump is sent" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ const high = try tones(a, 44100, &reply(130, 330)); -+ const lower = try tones(a, 44100, &reply(130, 290)); -+ const flat = try tones(a, 44100, &reply(130, 127)); -+ const on: Policy = .{ .register = rule }; -+ try expectSent(a, &.{ high, lower }, &.{}, on, "", 1, 2); -+ try expectSent(a, &.{ lower, high }, &.{}, on, "", 0, 2); -+ try expectSent(a, &.{ high, flat }, &.{ .stop, .silence }, on, "", 0, 2); -+ try expectSent(a, &.{flat}, &.{}, on, "", 0, 1); -+ try expectSent(a, &.{ high, flat }, &.{}, on, "X-Deadline-S: 1e-9\r\n", 0, 1); -+} -+ -+test "an unmeasured redraw keeps the tripping first take; an unmeasured first take does not trip" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ const high = try tones(a, 44100, &reply(130, 330)); -+ const zero = try a.alloc(f32, high.len); -+ @memset(zero, 0); -+ const single = try tones(a, 44100, &.{.{ 3.3, 130, 130 }}); -+ const flat = try tones(a, 44100, &reply(130, 127)); -+ const on: Policy = .{ .register = rule }; -+ for ([_][]const f32{ zero, single }) |unmeasured| { -+ try std.testing.expectEqual(null, try jump(a, unmeasured, 44100, rule)); -+ try expectSent(a, &.{ high, unmeasured }, &.{ .stop, .stop }, on, "", 0, 2); -+ try expectSent(a, &.{unmeasured}, &.{.stop}, on, "", 0, 1); -+ } -+ try expectSent(a, &.{ high, flat }, &.{ .stop, .stop }, on, "", 1, 2); -+} -+ -+test "the register guard measures the take silence redraws leave, and off sends the first take unmeasured" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ const high = try tones(a, 44100, &reply(130, 330)); -+ const flat = try tones(a, 44100, &reply(130, 127)); -+ try expectSent(a, &.{ flat, high, flat }, &.{ .silence, .stop, .stop }, .{ .max = 1, .register = rule }, "", 2, 3); -+ try expectSent(a, &.{ high, flat }, &.{}, .{}, "X-Deadline-S: soon\r\n", 0, 1); -+} -+ -+test "register metadata counts admission not selection and latches trips even without budget" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ const high = try tones(a, 44100, &reply(130, 330)); -+ const flat = try tones(a, 44100, &reply(130, 127)); -+ const lower = try tones(a, 44100, &reply(130, 290)); -+ const on: Policy = .{ .register = rule }; -+ const used = "X-Register-Trip: 1\r\nX-Register-Redraws: 1\r\n"; -+ try expectSentWith(a, &.{ high, flat }, &.{}, on, "", 1, 2, false, used); -+ try expectSentWith(a, &.{ lower, high }, &.{}, on, "", 0, 2, false, used); -+ try expectSentWith(a, &.{high}, &.{}, on, "X-Deadline-S: 1e-9\r\n", 0, 1, false, "X-Register-Trip: 1\r\nX-Register-Redraws: 0\r\n"); -+ try expectSentWith(a, &.{flat}, &.{}, on, "", 0, 1, false, "X-Register-Trip: 0\r\nX-Register-Redraws: 0\r\n"); -+ try expectSentWith(a, &.{ flat, high, flat }, &.{ .silence, .stop, .stop }, .{ .max = 1, .register = rule }, "", 2, 3, true, "X-Silence-Redraws: 1\r\nX-Register-Trip: 1\r\nX-Register-Redraws: 1\r\n"); -+} -+ -+test "register selection keeps ties, accepts a lower length take, and rejects silence" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ const high = try tones(a, 44100, &reply(130, 330)); -+ const flat = try tones(a, 44100, &reply(130, 127)); -+ const on: Policy = .{ .register = rule }; -+ try expectSentWith(a, &.{ high, high }, &.{ .stop, .length }, on, "", 0, 2, true, "X-Finish-Reason: stop\r\n"); -+ try expectSentWith(a, &.{ high, flat }, &.{ .stop, .length }, on, "", 1, 2, true, "X-Finish-Reason: length\r\n"); -+ try expectSentWith(a, &.{ high, flat }, &.{ .stop, .silence }, on, "", 0, 2, true, "X-Finish-Reason: stop\r\n"); -+} -+ -+test "an unmeasured register redraw can win only with the silence guard on and no silence finish" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ const high = try tones(a, 44100, &reply(130, 330)); -+ const unmeasured = try a.alloc(f32, high.len); -+ @memset(unmeasured, 0); -+ const on: Policy = .{ .register = rule }; -+ const used = "X-Register-Trip: 1\r\nX-Register-Redraws: 1\r\n"; -+ try expectSentWith(a, &.{ high, unmeasured }, &.{ .stop, .stop }, on, "", 1, 2, true, used); -+ try expectSentWith(a, &.{ high, unmeasured }, &.{ .stop, .silence }, on, "", 0, 2, true, used); -+ try expectSentWith(a, &.{ high, unmeasured }, &.{ .stop, .stop }, on, "", 0, 2, false, used); -+} -+ -+test "register headers are absent on streams and errors, and register-only streams validate deadlines" { -+ const Case = struct { body: []const u8, headers: []const u8 = "", status: []const u8 }; -+ for ([_]Case{ -+ .{ .body = harness.stream_body, .status = "HTTP/1.1 200" }, -+ .{ .body = harness.stream_body, .headers = "X-Deadline-S: soon\r\n", .status = "HTTP/1.1 400" }, -+ .{ .body = "{\"model\":\"m\"}", .status = "HTTP/1.1 400" }, -+ }) |case| { -+ var script: harness.Script = .{ .steps = &.{harness.frame}, .inline_ = true }; -+ var s: harness.Served = undefined; -+ try s.open(&script, .{ .served_name = "m", .model_ids = &.{"m"}, .redraw = .{ .register = rule } }, case.headers, "HTTP/1.1", case.body, 2000); -+ defer s.deinit(); -+ const head = head: { -+ defer s.join(&script); -+ _ = try s.readHead(); -+ _ = try s.readRest(); -+ break :head s.got.items[0..s.read_at]; -+ }; -+ try harness.expectHeader(head, case.status); -+ try std.testing.expect(std.mem.indexOf(u8, head, "X-Register-") == null); -+ try std.testing.expect(std.mem.indexOf(u8, head, "X-Silence-Redraws") == null); -+ } -+} -+ -+test "short register redraws cannot win and discarded mid trips still count" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ const high = try tones(a, 44100, &reply(130, 330)); -+ const flat = try tones(a, 44100, &reply(130, 127)); -+ const unmeasured = try a.alloc(f32, high.len); -+ @memset(unmeasured, 0); -+ const body = "{\"model\":\"m\",\"input\":\"" ++ @as([200]u8, @splat('a')) ++ "\",\"response_format\":\"pcm\"}"; -+ for ([_]bool{ false, true }) |mid| { -+ for ([_][]const f32{ flat, unmeasured }) |later| { -+ for ([_]bool{ false, true }) |clean| { -+ var script: harness.Script = .{ -+ .steps = &.{high.len}, -+ .audio = &.{ high, later, later }, -+ .reasons = &.{ .length, if (mid) .silence else .stop, if (clean) .length else .stop }, -+ .mid_takes = &.{ false, mid }, -+ .max_frames = @intCast(high.len / harness.frame + 1), -+ .inline_ = true, -+ }; -+ var s: harness.Served = undefined; -+ const policy: Policy = .{ .max = 1, .register = rule, .short_ratio = 0.6, .short_chars_per_s = 10 }; -+ try s.open(&script, .{ .served_name = "m", .model_ids = &.{"m"}, .redraw = policy, .silence_guard = true, .silence_mid = true }, "", "HTTP/1.1", body, 2000); -+ defer s.deinit(); -+ const audio = audio: { -+ defer s.join(&script); -+ _ = try s.readHead(); -+ break :audio try s.readRest(); -+ }; -+ const head = s.got.items[0..s.read_at]; -+ try harness.expectHeader(head, "X-Finish-Reason: length\r\nX-Silence-Redraws: 1\r\n"); -+ try harness.expectHeader(head, if (mid) "X-Silence-Mid-Trips: 1\r\n" else "X-Silence-Mid-Trips: 0\r\n"); -+ try harness.expectHeader(head, "X-Register-Trip: 1\r\nX-Register-Redraws: 1\r\n"); -+ try std.testing.expectEqualSlices(u8, try quantizeInPlace(try a.dupe(f32, if (clean) later else high)), audio); -+ try std.testing.expectEqual(@as(usize, 3), script.takes); -+ } -+ } -+ } -+} -+ -+test "register retries reenter the silence pipeline before comparison without overwriting the held take" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ const high = try tones(a, 44100, &reply(130, 330)); -+ const lower = try tones(a, 44100, &reply(130, 290)); -+ const higher = try tones(a, 44100, &reply(130, 390)); -+ const flat = try tones(a, 44100, &reply(130, 127)); -+ const on: Policy = .{ .max = 1, .register = rule }; -+ const used = "X-Silence-Redraws: 1\r\nX-Register-Trip: 1\r\nX-Register-Redraws: 1\r\n"; -+ try expectSentWith(a, &.{ high, flat, lower }, &.{ .stop, .silence, .stop }, on, "", 2, 3, true, used); -+ try expectSentWith(a, &.{ high, flat, higher }, &.{ .stop, .silence, .stop }, on, "", 0, 3, true, used); -+ try expectSentWith(a, &.{ flat, high, flat, lower }, &.{ .silence, .stop, .silence, .stop }, .{ .max = 2, .register = rule }, "", 3, 4, true, "X-Silence-Redraws: 2\r\nX-Register-Trip: 1\r\nX-Register-Redraws: 1\r\n"); -+ try expectSentWith(a, &.{ high, high }, &.{ .silence, .silence }, on, "", 1, 2, true, "X-Silence-Redraws: 1\r\nX-Register-Trip: 0\r\nX-Register-Redraws: 0\r\n"); -+} -diff --git a/zig/src/server/speech_stream_test.zig b/zig/src/server/speech_stream_test.zig -index bf6d15c9..05ecd5d9 100644 ---- a/zig/src/server/speech_stream_test.zig -+++ b/zig/src/server/speech_stream_test.zig -@@ -15,8 +15,11 @@ pub const Script = struct { - steps: []const usize, - reason: api.Reason = .stop, - silence_mid: bool = false, -+ mid_takes: []const bool = &.{}, - /// Each take's reason, then `reason` for later takes. - reasons: []const api.Reason = &.{}, -+ /// Each take's samples in place of the fill, then the fill for later takes. -+ audio: []const []const f32 = &.{}, - busy_from: usize = std.math.maxInt(usize), - mid_enabled: bool = false, - busy_redraws: usize = 0, -@@ -65,11 +68,11 @@ pub const Script = struct { - while (!e.release.load(.acquire)) std.Thread.yield() catch {}; - if (e.cancelled.load(.acquire)) return sink.event(sink.ctx, id, &.{ .finished = .{ .reason = .cancelled } }); - } -- @memset(request.pcm[done..n], 0.5 / @as(f32, @floatFromInt(take + 1))); -+ if (take < e.audio.len) @memcpy(request.pcm[done..n], e.audio[take][done..n]) else @memset(request.pcm[done..n], 0.5 / @as(f32, @floatFromInt(take + 1))); - if (request.stream) sink.event(sink.ctx, id, &.{ .audio = n }); - done = n; - } -- request.result.silence_mid = e.silence_mid; -+ request.result.silence_mid = if (take < e.mid_takes.len) e.mid_takes[take] else e.silence_mid; - if (reason == .stop or reason == .length or reason == .silence) request.result.samples = done; - request.result.reason = reason; - sink.event(sink.ctx, id, &.{ .finished = .{ .reason = reason, .message = e.message } }); -diff --git a/zig/src/server/speech_unmeasured_test.zig b/zig/src/server/speech_unmeasured_test.zig -new file mode 100644 -index 00000000..0ffbd52c ---- /dev/null -+++ b/zig/src/server/speech_unmeasured_test.zig -@@ -0,0 +1,40 @@ -+const std = @import("std"); -+const log = @import("log.zig"); -+const Server = @import("server.zig").Server; -+const Conn = @import("http_conn.zig").Conn; -+const Cx = @import("errors.zig").Cx; -+const Metadata = @import("speech_metadata.zig").Metadata; -+ -+const Capture = struct { -+ bytes: [256]u8 = undefined, -+ len: usize = 0, -+ -+ fn line(ctx: *anyopaque, text: []const u8) void { -+ const self: *Capture = @ptrCast(@alignCast(ctx)); -+ @memcpy(self.bytes[self.len..][0..text.len], text); -+ self.len += text.len; -+ } -+}; -+ -+pub fn check(comptime redraw: anytype) !void { -+ var srv: Server = undefined; -+ srv.config = .{ .served_name = "m", .model_ids = &.{"m"}, .redraw = .{ .register = .{ .st = 6, .high = 80, .high_hz = 250 } } }; -+ var conn: Conn = undefined; -+ var cx: Cx = .{ .a = std.testing.allocator }; -+ var spare: []f32 = &.{}; -+ var capture: Capture = .{}; -+ log.Test.sink = .{ .ctx = &capture, .call = Capture.line }; -+ defer log.Test.sink = null; -+ for ([_]f32{ std.math.nan(f32), 0 }, [_][]const u8{ "NonFinite", "no voiced phrase comparison" }) |sample, reason| { -+ capture.len = 0; -+ var pcm = [_]f32{sample}; -+ var metadata: Metadata = .{ .enabled = false, .register_on = true }; -+ const take = redraw(&srv, &conn, &cx, "register-test", .{ .sample_rate = 44100, .frame_length = 2048, .max_frames = 1024, .max_prompt_tokens = 4096 }, .{ .input = "hi", .prompt = &.{}, .seed = 0, .format = .pcm, .stream = false }, 0, undefined, 0, .{ .pcm = &pcm, .reason = .stop }, &spare, &metadata).?; -+ try std.testing.expect(take.jump == null); -+ try std.testing.expect(take.pcm.ptr == &pcm); -+ try std.testing.expect(!metadata.register_tripped and !metadata.register_redrawn); -+ try std.testing.expectEqual(@as(usize, 0), spare.len); -+ var expected: [256]u8 = undefined; -+ try std.testing.expectEqualStrings(try std.fmt.bufPrint(&expected, "[tensorfold] speech register-test register unmeasured: {s}\n", .{reason}), capture.bytes[0..capture.len]); -+ } -+} diff --git a/engine/patches/0012-feat-drama3-reuse-a-voice-reference-s-prefill-across.patch b/engine/patches/0012-feat-drama3-reuse-a-voice-reference-s-prefill-across.patch deleted file mode 100644 index cba0078..0000000 --- a/engine/patches/0012-feat-drama3-reuse-a-voice-reference-s-prefill-across.patch +++ /dev/null @@ -1,770 +0,0 @@ -From c265ca2fc4b9b54ba75b6780e110c566483c93d2 Mon Sep 17 00:00:00 2001 -From: build-engine -Date: Fri, 23 Oct 2026 11:49:44 +0000 -Subject: [PATCH] feat(drama3): reuse a voice reference's prefill across - requests - -Keep one live KV prefix and one reusable recurrent-state snapshot pair per -Metal lane. The prompt builder reports the invariant reference boundary; -cache identity includes the tower lookahead row and compares exact owned -frames rather than a hash alone. Preserve original prefill chunk cuts. - -Invalidate before incompatible/default prefill and on execution failure. -Reuse GPU-owned buffers across voice switches, including partial-allocation -failures. Rebuild per-request decode counters and leave CUDA uncached. - -Add deterministic boundary, exactness, invalidation, ownership and prompt -regressions. Corrects PR17's stale A/default/A state and per-switch leak. - -Link the Metal/Objective-C frameworks for the macOS host-check target. ---- - zig/build/drama3_host.zig | 3 + - zig/build/server.zig | 1 + - zig/src/core/speech_api.zig | 3 + - zig/src/families/drama3/drama3.zig | 2 + - zig/src/families/drama3/drama3_metal.zig | 3 + - zig/src/families/drama3/generate.zig | 2 +- - zig/src/families/drama3/metal/engine.zig | 26 +++ - zig/src/families/drama3/prompt.zig | 117 ++++++++++- - zig/src/families/drama3/reference_cache.zig | 208 ++++++++++++++++++++ - zig/src/native/drama_host.zig | 59 +++++- - zig/src/server/drama_voice.zig | 5 +- - zig/src/server/speech.zig | 13 +- - zig/src/server/speech_stream_test.zig | 6 +- - 13 files changed, 427 insertions(+), 21 deletions(-) - create mode 100644 zig/src/families/drama3/reference_cache.zig - -diff --git a/zig/build/drama3_host.zig b/zig/build/drama3_host.zig -index d63344ad..4dbbd4c5 100644 ---- a/zig/build/drama3_host.zig -+++ b/zig/build/drama3_host.zig -@@ -32,6 +32,9 @@ pub fn native(b: *std.Build, step: *std.Build.Step, family: *std.Build.Module, c - pub fn metal(b: *std.Build, core: *std.Build.Module, api: *std.Build.Module) void { - if (b.graph.host.result.os.tag != .macos) return; - const runtime = b.createModule(.{ .root_source_file = b.path("zig/src/metal/metal.zig"), .target = b.graph.host }); -+ runtime.linkFramework("Metal", .{}); -+ runtime.linkFramework("Foundation", .{}); -+ runtime.linkSystemLibrary("objc", .{}); - const bytes = b.addWriteFiles().add("drama3_host_metallib.zig", "pub const bytes: []const u8 = &.{};\n"); - const family = b.createModule(.{ - .root_source_file = b.path("zig/src/families/drama3/drama3_metal.zig"), -diff --git a/zig/build/server.zig b/zig/build/server.zig -index e32bc9f7..b1ae00d5 100644 ---- a/zig/build/server.zig -+++ b/zig/build/server.zig -@@ -24,6 +24,7 @@ pub fn addTo( - if (family) |drama3| { - const source = b.addWriteFiles().add("drama_prompt.zig", - \\pub const Reference = @import("drama3").prompt.Reference; -+ \\pub const Built = @import("drama3").prompt.Built; - \\pub const build = @import("drama3").prompt.build; - ); - m.addImport("drama_prompt", b.createModule(.{ -diff --git a/zig/src/core/speech_api.zig b/zig/src/core/speech_api.zig -index 4839cfee..a733df7e 100644 ---- a/zig/src/core/speech_api.zig -+++ b/zig/src/core/speech_api.zig -@@ -14,6 +14,9 @@ pub const SpeechRequest = struct { - top_k: u32 = 30, - /// Publishes decoded prefixes; whole replies also decode incrementally when a speech guard is enabled. - stream: bool = false, -+ /// End of the invariant reference prompt, excluding request text; 0 disables reuse. -+ /// The backend must leave any lookahead-dependent row outside its saved state. -+ reference_rows: u32 = 0, - }; - - pub const SpeechInfo = struct { -diff --git a/zig/src/families/drama3/drama3.zig b/zig/src/families/drama3/drama3.zig -index 227c8357..88e31598 100644 ---- a/zig/src/families/drama3/drama3.zig -+++ b/zig/src/families/drama3/drama3.zig -@@ -3,6 +3,7 @@ pub const completion = @import("completion.zig"); - pub const speech_decode = @import("speech_decode.zig"); - pub const model = @import("model.zig"); - pub const kv_state = @import("kv_state.zig"); -+pub const reference_cache = @import("reference_cache.zig"); - pub const codec_load = @import("codec_load.zig"); - pub const speed_probe = @import("cuda/speed_probe.zig"); - pub const replay = @import("replay.zig").Replay(@This()); -@@ -29,6 +30,7 @@ test { - _ = config; - _ = speed_probe; - _ = model; -+ _ = reference_cache; - _ = kv_state; - _ = @import("codec_map.zig"); - _ = @import("codec_schema.zig"); -diff --git a/zig/src/families/drama3/drama3_metal.zig b/zig/src/families/drama3/drama3_metal.zig -index 6ae08662..5001b377 100644 ---- a/zig/src/families/drama3/drama3_metal.zig -+++ b/zig/src/families/drama3/drama3_metal.zig -@@ -3,6 +3,7 @@ pub const completion = @import("completion.zig"); - pub const speech_decode = @import("speech_decode.zig"); - pub const model = @import("model.zig"); - pub const kv_state = @import("kv_state.zig"); -+pub const reference_cache = @import("reference_cache.zig"); - pub const codec_load = @import("codec_load.zig"); - pub const replay = @import("replay.zig").Replay(@This()); - pub const config = @import("config.zig"); -@@ -18,6 +19,7 @@ pub const codec = @import("metal/codec.zig"); - pub const tts = @import("metal/tts.zig"); - - pub const Engine = engine.Engine; -+pub const Snapshot = engine.Engine.Snapshot; - pub const Sampling = engine.Sampling; - pub const Options = engine.Options; - -@@ -28,6 +30,7 @@ test { - _ = config; - _ = model; - _ = kv_state; -+ _ = reference_cache; - _ = @import("codec_map.zig"); - _ = @import("codec_schema.zig"); - _ = @import("codec_pth.zig"); -diff --git a/zig/src/families/drama3/generate.zig b/zig/src/families/drama3/generate.zig -index 00c0d302..4b0a17cb 100644 ---- a/zig/src/families/drama3/generate.zig -+++ b/zig/src/families/drama3/generate.zig -@@ -252,7 +252,7 @@ fn runRequest(gpa: std.mem.Allocator, io: std.Io, a: Args, e: *d3.Engine, tts: ? - nref = 1; - } - if (nref > 0) ref_frames = refs[0].codes.len; -- rows = try d3.prompt.build(ar, tok, text, refs[0..nref]); -+ rows = (try d3.prompt.build(ar, tok, text, refs[0..nref])).frames; - } - if (a.replay) try d3.replay.verify(gpa, io, e, tts orelse return error.CodecRequired, rows, q.smp); - if (a.speed_probe) { -diff --git a/zig/src/families/drama3/metal/engine.zig b/zig/src/families/drama3/metal/engine.zig -index 00db2d8a..54f761c3 100644 ---- a/zig/src/families/drama3/metal/engine.zig -+++ b/zig/src/families/drama3/metal/engine.zig -@@ -294,6 +294,32 @@ pub const Engine = struct { - e.rows_done = n; - } - -+ /// Recurrent state only. The caller must invalidate it before its lane's live prefix KV changes. -+ pub const Snapshot = @import("../reference_cache.zig").Snapshot; -+ -+ /// Captures into one reusable, GPU-owned buffer pair (including partial allocation on failure). -+ pub fn snapshot(e: *Engine, saved: *Snapshot) !void { -+ try saved.capture(e.g, e.state_bufs, e.state_bytes); -+ } -+ -+ pub fn restore(e: *Engine, saved: *const Snapshot) !void { -+ try saved.restore(e.g, e.state_bufs, e.state_bytes); -+ } -+ -+ /// Starts a prompt whose first `start` rows are already in the engine's state; writes the rest, no reset. -+ pub fn beginPromptFrom(e: *Engine, rows: []const [W]i32, start: usize) !void { -+ try model.validate(rows, e.c.ids, e.o.max_ctx, 1); -+ const n = rows.len; -+ if (n < 2 or start == 0 or start >= n) return error.PromptTooShort; -+ if (n + 2 > e.o.max_ctx) return error.PromptTooLong; -+ try e.put(e.pframes, std.mem.sliceAsBytes(rows)); -+ // The suffix prefill/finishPrompt rebuilds all other decode state, including hlast and RAS. -+ @memset(e.g.host(e.count, 8), 0); -+ @memset(e.g.host(e.step, 8), 0); -+ e.rows_done = start; -+ e.prompt_rows = start; -+ } -+ - fn tower(e: *Engine, tt: usize, c0: usize, hfin: u64, probe: ?Probe) !void { - var hin = hfin; - var nb: [64]u8 = undefined; -diff --git a/zig/src/families/drama3/prompt.zig b/zig/src/families/drama3/prompt.zig -index 6b3a8749..e9e40b6c 100644 ---- a/zig/src/families/drama3/prompt.zig -+++ b/zig/src/families/drama3/prompt.zig -@@ -58,7 +58,13 @@ fn refTranscript(a: std.mem.Allocator, transcript: []const u8) ![]u8 { - return std.mem.concat(a, u8, &.{ body[0..tl], tagged[0..lead], body[tl..] }); - } - --pub fn build(gpa: std.mem.Allocator, tok: anytype, text: []const u8, refs: []const Reference) ![][W]i32 { -+pub const Built = struct { -+ frames: [][W]i32, -+ /// End of the invariant reference prompt, before request text (0 without references). -+ reference_rows: u32, -+}; -+ -+pub fn build(gpa: std.mem.Allocator, tok: anytype, text: []const u8, refs: []const Reference) !Built { - if (text.len == 0 or text.len > 16384 or !std.unicode.utf8ValidateSlice(text) or refs.len > 4) return error.BadPrompt; - for (refs) |r| { - if (r.transcript.len == 0 or r.transcript.len > 4096 or !std.unicode.utf8ValidateSlice(r.transcript) or -@@ -73,7 +79,7 @@ pub fn build(gpa: std.mem.Allocator, tok: anytype, text: []const u8, refs: []con - const sem_begin = tok.specialTokenId("<|semantic:0|>") orelse return error.NoSemanticToken; - var rows: std.ArrayList([W]i32) = .empty; - errdefer rows.deinit(gpa); -- const Seg = union(enum) { text: []const u8, codes: []const [cfg.codebooks]i32 }; -+ const Seg = union(enum) { text: []const u8, codes: []const [cfg.codebooks]i32, reference_end }; - var segs: std.ArrayList(Seg) = .empty; - try segs.append(a, .{ .text = "<|im_start|>system\n" }); - try segs.append(a, .{ .text = if (refs.len > 0) reference_instruction else tts_instruction }); -@@ -84,9 +90,11 @@ pub fn build(gpa: std.mem.Allocator, tok: anytype, text: []const u8, refs: []con - } - try segs.append(a, .{ .text = message_end }); - try segs.append(a, .{ .text = "<|im_start|>user\n" }); -+ try segs.append(a, .reference_end); - try segs.append(a, .{ .text = try withSpeaker(a, text) }); - try segs.append(a, .{ .text = message_end }); - try segs.append(a, .{ .text = generation_start }); -+ var reference_rows: u32 = 0; - for (segs.items) |seg| switch (seg) { - .text => |t| { - const ids = try tok.encode(a, t); -@@ -102,8 +110,11 @@ pub fn build(gpa: std.mem.Allocator, tok: anytype, text: []const u8, refs: []con - for (0..cfg.codebooks) |c| row[1 + c] = f[c]; - try rows.append(gpa, row); - }, -+ .reference_end => if (refs.len > 0) { -+ reference_rows = @intCast(rows.items.len); -+ }, - }; -- return rows.toOwnedSlice(gpa); -+ return .{ .frames = try rows.toOwnedSlice(gpa), .reference_rows = reference_rows }; - } - - test "speaker tags as prompt.py places them" { -@@ -115,3 +126,103 @@ test "speaker tags as prompt.py places them" { - try std.testing.expectEqualStrings("<|speaker:1|> hi", try refTranscript(a, " <|speaker:1|> hi")); - try std.testing.expectEqualStrings("<|speaker:0|> We asked", try refTranscript(a, "We asked")); - } -+ -+const ByteTokenizer = struct { -+ pub fn specialTokenId(_: @This(), _: []const u8) ?u32 { -+ return 1000; -+ } -+ -+ pub fn encode(_: @This(), a: std.mem.Allocator, text: []const u8) ![]u32 { -+ const ids = try a.alloc(u32, text.len); -+ for (text, ids) |byte, *id| id.* = byte; -+ return ids; -+ } -+}; -+ -+fn expectTextRows(text: []const u8, rows: []const [W]i32) !void { -+ try std.testing.expectEqual(text.len, rows.len); -+ for (text, rows) |byte, row| { -+ var expected: [W]i32 = @splat(0); -+ expected[0] = byte; -+ try std.testing.expectEqualSlices(i32, &expected, &row); -+ } -+} -+ -+test "reference prompt boundary preserves every row and ends before request text" { -+ const t = std.testing; -+ const codes = [_][cfg.codebooks]i32{.{ 7, 1, 2, 3, 4, 5, 6, 7, 8, 9 }}; -+ const refs = [_]Reference{.{ .transcript = "Ref.", .codes = &codes }}; -+ const built = try build(t.allocator, ByteTokenizer{}, "One.", &refs); -+ defer t.allocator.free(built.frames); -+ const before = "<|im_start|>system\n" ++ reference_instruction ++ -+ "\n\n[Reference 1]\nText: <|speaker:0|> Ref.\nSpeech: "; -+ const after = message_end ++ "<|im_start|>user\n"; -+ const suffix = "<|speaker:0|> One." ++ message_end ++ generation_start; -+ const boundary = before.len + codes.len + after.len; -+ try t.expectEqual(@as(u32, boundary), built.reference_rows); -+ try t.expectEqual(boundary + suffix.len, built.frames.len); -+ try expectTextRows(before, built.frames[0..before.len]); -+ try t.expectEqualSlices(i32, &.{ 1007, 7, 1, 2, 3, 4, 5, 6, 7, 8, 9 }, &built.frames[before.len]); -+ try expectTextRows(after, built.frames[before.len + 1 .. boundary]); -+ try expectTextRows(suffix, built.frames[boundary..]); -+ -+ const other = try build(t.allocator, ByteTokenizer{}, " <|speaker:1|> Different text.", &refs); -+ defer t.allocator.free(other.frames); -+ try t.expectEqual(built.reference_rows, other.reference_rows); -+ try t.expectEqualSlices(u8, std.mem.sliceAsBytes(built.frames[0..boundary]), std.mem.sliceAsBytes(other.frames[0..boundary])); -+ try expectTextRows(" <|speaker:1|> Different text." ++ message_end ++ generation_start, other.frames[boundary..]); -+ -+ const plain = try build(t.allocator, ByteTokenizer{}, "One.", &.{}); -+ defer t.allocator.free(plain.frames); -+ try t.expectEqual(@as(u32, 0), plain.reference_rows); -+ try expectTextRows("<|im_start|>system\n" ++ tts_instruction ++ message_end ++ "<|im_start|>user\n" ++ suffix, plain.frames); -+} -+ -+test "reference prompt boundary includes all transcripts, wrappers and multiple references" { -+ const t = std.testing; -+ const codes = [_][cfg.codebooks]i32{ @splat(1), @splat(2) }; -+ const refs = [_]Reference{ -+ .{ .transcript = " <|speaker:1|> Ref.", .codes = &codes }, -+ .{ .transcript = "Second.", .codes = codes[0..1] }, -+ }; -+ const built = try build(t.allocator, ByteTokenizer{}, "One.", &refs); -+ defer t.allocator.free(built.frames); -+ const before = "<|im_start|>system\n" ++ reference_instruction ++ -+ "\n\n[Reference 1]\nText: <|speaker:1|> Ref.\nSpeech: "; -+ const between = "\n\n[Reference 2]\nText: <|speaker:0|> Second.\nSpeech: "; -+ const after = message_end ++ "<|im_start|>user\n"; -+ const boundary = before.len + codes.len + between.len + 1 + after.len; -+ try t.expectEqual(@as(u32, boundary), built.reference_rows); -+ try expectTextRows(before, built.frames[0..before.len]); -+ try expectTextRows(between, built.frames[before.len + 2 ..][0..between.len]); -+ try expectTextRows(after, built.frames[boundary - after.len .. boundary]); -+ try expectTextRows("<|speaker:0|> One." ++ message_end ++ generation_start, built.frames[boundary..]); -+} -+ -+test "reference cache: last reference code and equal-length changed tower lookahead both miss" { -+ const t = std.testing; -+ const cache = @import("reference_cache.zig"); -+ var prefix: cache.Prefix = .{}; -+ defer prefix.deinit(t.allocator); -+ var codes = [_][cfg.codebooks]i32{ @splat(1), @splat(2) }; -+ const refs = [_]Reference{.{ .transcript = "Ref.", .codes = &codes }}; -+ const first = try build(t.allocator, ByteTokenizer{}, "One.", &refs); -+ defer t.allocator.free(first.frames); -+ const saved_rows = cache.prefillRows(first.frames.len, first.reference_rows, 1); -+ try t.expect(!try prefix.begin(t.allocator, first.frames, saved_rows)); -+ prefix.saved(); -+ -+ codes[codes.len - 1][cfg.codebooks - 1] += 1; -+ const changed = try build(t.allocator, ByteTokenizer{}, "One.", &refs); -+ defer t.allocator.free(changed.frames); -+ try t.expectEqual(first.reference_rows, changed.reference_rows); -+ try t.expectEqual(saved_rows, cache.prefillRows(changed.frames.len, changed.reference_rows, 1)); -+ try t.expect(!try prefix.begin(t.allocator, changed.frames, saved_rows)); -+ prefix.saved(); -+ try t.expect(!try prefix.begin(t.allocator, first.frames, saved_rows)); -+ prefix.saved(); -+ -+ // Every processed row is unchanged; only the next row read by the tower differs. -+ first.frames[saved_rows][0] += 1; -+ try t.expect(!try prefix.begin(t.allocator, first.frames, saved_rows)); -+} -diff --git a/zig/src/families/drama3/reference_cache.zig b/zig/src/families/drama3/reference_cache.zig -new file mode 100644 -index 00000000..5504900a ---- /dev/null -+++ b/zig/src/families/drama3/reference_cache.zig -@@ -0,0 +1,208 @@ -+//! One live KV prefix per lane, identified exactly through the tower's next-row dependency. -+const std = @import("std"); -+const Frame = @import("model.zig").Frame; -+ -+/// Stop on an existing chunk boundary, with the tower's lookahead still inside the invariant prompt. -+/// Keeping the original chunk cuts also keeps the uncached prefill's numerical path. -+pub fn prefillRows(total: usize, reference_rows: usize, chunk: usize) usize { -+ if (reference_rows < 2 or reference_rows >= total) return 0; -+ std.debug.assert(chunk > 0); -+ return (reference_rows - 1) / chunk * chunk; -+} -+ -+pub const Prefix = struct { -+ rows: std.ArrayList(Frame) = .empty, // saved rows plus one tower lookahead row; never borrows a request -+ valid: bool = false, -+ -+ pub fn deinit(p: *Prefix, a: std.mem.Allocator) void { -+ p.rows.deinit(a); -+ p.* = .{}; -+ } -+ -+ pub fn invalidate(p: *Prefix) void { -+ p.valid = false; -+ } -+ -+ /// Called before reset/prefill can overwrite live KV. A miss invalidates even if allocation fails -+ /// or the caller is cancelled before saving the replacement recurrent state. -+ pub fn begin(p: *Prefix, a: std.mem.Allocator, frames: []const Frame, saved_rows: usize) !bool { -+ if (saved_rows > 0) { -+ std.debug.assert(saved_rows < frames.len); -+ const identity = frames[0 .. saved_rows + 1]; -+ if (p.valid and std.mem.eql(u8, std.mem.sliceAsBytes(p.rows.items), std.mem.sliceAsBytes(identity))) return true; -+ p.invalidate(); -+ try p.rows.resize(a, identity.len); -+ @memcpy(p.rows.items, identity); -+ } else { -+ p.invalidate(); -+ p.rows.clearRetainingCapacity(); -+ } -+ return false; -+ } -+ -+ /// Only after both the prefix prefill and recurrent-state copy have completed successfully. -+ pub fn saved(p: *Prefix) void { -+ std.debug.assert(p.rows.items.len >= 2); -+ p.valid = true; -+ } -+}; -+ -+/// Just recurrent state: KV stays live in its lane. Registered buffers belong to the GPU owner. -+/// Recording each allocation immediately retains ownership if the second allocation/copy fails; -+/// subsequent captures reuse the same pair, and GPU close releases even a partially allocated pair. -+pub const Snapshot = struct { -+ buffers: [2]u64 = .{ 0, 0 }, -+ -+ pub fn capture(s: *Snapshot, g: anytype, live: [2]u64, bytes: [2]usize) !void { -+ try g.flush(); -+ for (0..2) |i| if (s.buffers[i] == 0) { -+ s.buffers[i] = try g.alloc(bytes[i]); -+ }; -+ for (0..2) |i| g.copy(live[i], s.buffers[i], bytes[i]); -+ try g.flush(); -+ } -+ -+ pub fn restore(s: *const Snapshot, g: anytype, live: [2]u64, bytes: [2]usize) !void { -+ for (0..2) |i| { -+ std.debug.assert(s.buffers[i] != 0); -+ g.copy(s.buffers[i], live[i], bytes[i]); -+ } -+ try g.flush(); -+ } -+}; -+ -+test "reference cache: boundary excludes tower lookahead and preserves original chunk cuts" { -+ const t = std.testing; -+ try t.expectEqual(@as(usize, 9), prefillRows(20, 10, 1)); -+ try t.expectEqual(@as(usize, 8), prefillRows(20, 10, 4)); -+ try t.expectEqual(@as(usize, 0), prefillRows(20, 4, 4)); -+ for ([_]usize{ 0, 1, 20, 21, std.math.maxInt(usize) }) |boundary| -+ try t.expectEqual(@as(usize, 0), prefillRows(20, boundary, 1)); -+ for ([_]usize{ 1, 2, 4, 16 }) |chunk| { -+ for (2..20) |boundary| { -+ const rows = prefillRows(20, boundary, chunk); -+ try t.expect(rows < boundary); -+ try t.expectEqual(@as(usize, 0), rows % chunk); -+ } -+ } -+} -+ -+test "reference cache: exact owned identity covers every prefix and tower lookahead cell" { -+ const t = std.testing; -+ var p: Prefix = .{}; -+ defer p.deinit(t.allocator); -+ const frames: [6]Frame = @splat(@splat(7)); -+ try t.expect(!try p.begin(t.allocator, &frames, 3)); -+ p.saved(); -+ var changed = frames; -+ changed[4][0] = 42; // request text beyond the lookahead does not change the saved state -+ try t.expect(try p.begin(t.allocator, &changed, 3)); -+ try t.expect(try p.begin(t.allocator, changed[0..5], 3)); -+ for (0..4) |row| { -+ for (0..11) |col| { -+ changed = frames; -+ changed[row][col] += 1; -+ try t.expect(!try p.begin(t.allocator, &changed, 3)); -+ p.saved(); -+ changed[row][col] = frames[row][col]; // the identity must not borrow the caller's buffer -+ try t.expect(!try p.begin(t.allocator, &frames, 3)); -+ p.saved(); -+ } -+ } -+ try t.expect(!try p.begin(t.allocator, &frames, 2)); // equal contents, different prefix length -+} -+ -+test "reference cache: default, partial replacement and allocation errors invalidate before overwrite" { -+ const t = std.testing; -+ var p: Prefix = .{}; -+ defer p.deinit(t.allocator); -+ const a: [5]Frame = @splat(@splat(1)); -+ const b: [5]Frame = @splat(@splat(2)); -+ _ = try p.begin(t.allocator, &a, 3); -+ p.saved(); -+ try t.expect(try p.begin(t.allocator, &a, 3)); -+ // Cancelling a same-prefix suffix leaves prefix KV and the saved recurrence untouched. -+ try t.expect(try p.begin(t.allocator, &a, 3)); -+ try t.expect(!try p.begin(t.allocator, &b, 0)); // cancellation during uncached/default prefill -+ try t.expect(!p.valid); // even before a replacement snapshot can be reached -+ try t.expect(!try p.begin(t.allocator, &a, 3)); -+ p.saved(); -+ try t.expect(!try p.begin(t.allocator, &b, 3)); // cancelled/error before a replacement snapshot -+ try t.expect(!p.valid); -+ try t.expect(!try p.begin(t.allocator, &a, 3)); -+ p.saved(); -+ const larger: [100]Frame = @splat(@splat(3)); -+ var failing = std.testing.FailingAllocator.init(t.allocator, .{ .fail_index = 0, .resize_fail_index = 0 }); -+ try t.expectError(error.OutOfMemory, p.begin(failing.allocator(), &larger, 99)); -+ try t.expect(!p.valid); -+ try t.expect(!try p.begin(t.allocator, &a, 3)); -+ p.saved(); -+ p.invalidate(); // an execution error after a hit cannot publish a reusable prefix -+ try t.expect(!try p.begin(t.allocator, &a, 3)); -+} -+ -+/// Small CPU buffers exercise the same capture/restore and partial-allocation ownership as Metal. -+const TestGpu = struct { -+ buffers: std.ArrayList([]u8) = .empty, -+ fail_at: ?usize = null, -+ -+ pub fn alloc(g: *TestGpu, bytes: usize) !u64 { -+ if (g.fail_at == g.buffers.items.len) return error.OutOfMemory; -+ const buffer = try std.testing.allocator.alloc(u8, bytes); -+ errdefer std.testing.allocator.free(buffer); -+ @memset(buffer, 0); -+ try g.buffers.append(std.testing.allocator, buffer); -+ return @intCast(g.buffers.items.len); -+ } -+ -+ pub fn flush(_: *TestGpu) !void {} -+ -+ pub fn copy(g: *TestGpu, from: u64, to: u64, bytes: usize) void { -+ @memcpy(g.buffers.items[@intCast(to - 1)][0..bytes], g.buffers.items[@intCast(from - 1)][0..bytes]); -+ } -+ -+ fn deinit(g: *TestGpu) void { -+ for (g.buffers.items) |b| std.testing.allocator.free(b); -+ g.buffers.deinit(std.testing.allocator); -+ } -+}; -+ -+test "reference cache: switches reuse one snapshot pair and restore the latest recurrent state" { -+ const t = std.testing; -+ var g: TestGpu = .{}; -+ defer g.deinit(); -+ const bytes: [2]usize = .{ 4, 8 }; -+ const live: [2]u64 = .{ try g.alloc(bytes[0]), try g.alloc(bytes[1]) }; -+ var snapshot: Snapshot = .{}; -+ for (1..33) |voice| { -+ const value: u8 = @intCast(voice); -+ for (g.buffers.items[0..2]) |buffer| @memset(buffer, value); -+ try snapshot.capture(&g, live, bytes); -+ try t.expectEqual(@as(usize, 4), g.buffers.items.len); -+ for (g.buffers.items[0..2]) |buffer| @memset(buffer, 0); -+ try snapshot.restore(&g, live, bytes); -+ for (g.buffers.items[0..2]) |buffer| for (buffer) |b| try t.expectEqual(value, b); -+ } -+} -+ -+test "reference cache: partial snapshot allocation stays owned and is reused or freed at close" { -+ const t = std.testing; -+ for ([_]bool{ false, true }) |retry| { -+ var g: TestGpu = .{}; -+ defer g.deinit(); -+ const bytes: [2]usize = .{ 4, 8 }; -+ const live: [2]u64 = .{ try g.alloc(bytes[0]), try g.alloc(bytes[1]) }; -+ var snapshot: Snapshot = .{}; -+ g.fail_at = 3; -+ try t.expectError(error.OutOfMemory, snapshot.capture(&g, live, bytes)); -+ try t.expectEqual(@as(usize, 3), g.buffers.items.len); -+ try t.expectEqual(@as(u64, 3), snapshot.buffers[0]); -+ try t.expectEqual(@as(u64, 0), snapshot.buffers[1]); -+ if (retry) { -+ g.fail_at = null; -+ try snapshot.capture(&g, live, bytes); -+ try t.expectEqual(@as(usize, 4), g.buffers.items.len); -+ try t.expectEqual(@as(u64, 3), snapshot.buffers[0]); -+ } -+ } -+} -diff --git a/zig/src/native/drama_host.zig b/zig/src/native/drama_host.zig -index d8b05a7e..cf591494 100644 ---- a/zig/src/native/drama_host.zig -+++ b/zig/src/native/drama_host.zig -@@ -5,13 +5,22 @@ const d3 = @import("drama3"); - const completion = d3.completion; - const decode = d3.speech_decode; - const rounds = @import("core").frame_rounds; -+const reference = d3.reference_cache; - const is_cuda = !@hasDecl(d3, "gpu"); - const cuda = if (is_cuda) @import("cuda") else struct {}; - const Limit = if (is_cuda) @import("drama_limit.zig").Limit else struct { - fn init(_: *@This(), _: std.mem.Allocator, _: std.Io, _: u32) !void {} - fn deinit(_: *@This()) void {} - }; --const Session = struct { e: *d3.Engine, t: *d3.tts.Tts }; -+const Session = struct { -+ e: *d3.Engine, -+ t: *d3.tts.Tts, -+ /// Valid only while this lane's prefix KV remains untouched; snapshots own no KV copies. -+ prefix: reference.Prefix = .{}, -+ cache: Snap = .{}, -+}; -+/// Metal snapshot buffers belong to the shared GPU owner; CUDA keeps its uncached path. -+const Snap = if (is_cuda) struct {} else d3.Snapshot; - const Job = struct { - id: api.Id, - request: *const api.SpeechRequest, -@@ -166,18 +175,48 @@ const Host = struct { - } - fn advance(h: *Host, work: rounds.Work) !void { - const i = work.lane; -- const s = h.sessions[i]; -+ const lane = &h.sessions[i]; -+ errdefer if (!is_cuda) lane.prefix.invalidate(); -+ const s = lane.*; - const j = &h.jobs[i].?; - const r = j.request; - if (work.stage == .prompt) { -- if (!j.state.started) { -- try s.e.startPrompt(r.frames); -- if (is_cuda) try s.t.c.reset(s.t.cs) else try s.t.c.reset(); -- j.state.started = true; -+ if (is_cuda) { -+ if (!j.state.started) { -+ try s.e.startPrompt(r.frames); -+ try s.t.c.reset(s.t.cs); -+ j.state.started = true; -+ } -+ const count = s.e.o.promptRows(r.frames.len - j.state.prompt); -+ try s.e.promptChunk(r.frames.len, j.state.prompt, count, null); -+ j.state.prompt += count; -+ } else { -+ const ref_rows = reference.prefillRows(r.frames.len, r.reference_rows, s.e.o.max_rows); -+ if (!j.state.started) { -+ // Invalidate before reset, GPU work or any fallible preparation for a miss. -+ if (try lane.prefix.begin(h.gpa, r.frames, ref_rows)) { -+ try s.e.restore(&lane.cache); -+ try s.e.beginPromptFrom(r.frames, ref_rows); -+ j.state.prompt = ref_rows; -+ std.log.info("drama3: reference cache hit ({d} rows restored)", .{ref_rows}); -+ } else { -+ try s.e.startPrompt(r.frames); -+ j.state.prompt = 0; -+ } -+ try s.t.c.reset(); -+ j.state.started = true; -+ } -+ var count = s.e.o.promptRows(r.frames.len - j.state.prompt); -+ const want_snapshot = ref_rows > 0 and !lane.prefix.valid; -+ if (want_snapshot and j.state.prompt < ref_rows) count = @min(count, ref_rows - j.state.prompt); -+ try s.e.promptChunk(r.frames.len, j.state.prompt, count, null); -+ j.state.prompt += count; -+ if (want_snapshot and j.state.prompt == ref_rows) { -+ try s.e.snapshot(&lane.cache); -+ lane.prefix.saved(); -+ std.log.info("drama3: reference cache built ({d} rows)", .{ref_rows}); -+ } - } -- const count = s.e.o.promptRows(r.frames.len - j.state.prompt); -- try s.e.promptChunk(r.frames.len, j.state.prompt, count, null); -- j.state.prompt += count; - if (j.state.prompt == r.frames.len) { - try s.e.finishPrompt(r.frames); - h.lock(); -@@ -253,6 +292,7 @@ const Host = struct { - fn close(p: *anyopaque) void { - const h = self(p); - drain(h); -+ for (h.sessions[0..h.queue.lanes]) |*s| if (!is_cuda) s.prefix.deinit(h.gpa); - var safe = true; - for (h.sessions[0..h.queue.lanes]) |*s| waitSession(s) catch { - safe = false; -@@ -292,6 +332,7 @@ pub fn open(gpa: std.mem.Allocator, io: std.Io, dir: []const u8, _: []const u8, - var made: usize = 0; - errdefer while (made > 0) { - made -= 1; -+ if (!is_cuda) h.sessions[made].prefix.deinit(gpa); - h.sessions[made].t.destroy(); - h.sessions[made].e.destroy(); - }; -diff --git a/zig/src/server/drama_voice.zig b/zig/src/server/drama_voice.zig -index 576cc43d..08734f3d 100644 ---- a/zig/src/server/drama_voice.zig -+++ b/zig/src/server/drama_voice.zig -@@ -94,7 +94,7 @@ const Tokenizer = struct { - } - }; - --pub fn build(a: Allocator, tokenizer: text_mod.Text, input: []const u8, refs: []const prompt.Reference) ![][11]i32 { -+pub fn build(a: Allocator, tokenizer: text_mod.Text, input: []const u8, refs: []const prompt.Reference) !prompt.Built { - return prompt.build(a, Tokenizer{ .value = tokenizer }, input, refs); - } - -@@ -204,5 +204,6 @@ test "voices: real tokenizer rows equal the CLI path without server postprocessi - const refs = [_]prompt.Reference{.{ .transcript = "A small cloud passes.", .codes = &codes }}; - const cli_rows = try prompt.build(a, &tok, "A cloud.", &refs); - const http_rows = try build(a, hf.text(), "A cloud.", &refs); -- try std.testing.expectEqualSlices(u8, std.mem.sliceAsBytes(cli_rows), std.mem.sliceAsBytes(http_rows)); -+ try std.testing.expectEqualSlices(u8, std.mem.sliceAsBytes(cli_rows.frames), std.mem.sliceAsBytes(http_rows.frames)); -+ try std.testing.expectEqual(cli_rows.reference_rows, http_rows.reference_rows); - } -diff --git a/zig/src/server/speech.zig b/zig/src/server/speech.zig -index c9002e0c..314b1180 100644 ---- a/zig/src/server/speech.zig -+++ b/zig/src/server/speech.zig -@@ -42,7 +42,7 @@ pub const Format = enum { - } - }; - --const Plan = struct { input: []const u8, prompt: []const u32, frames: ?[]const [11]i32 = null, refs: []const @import("drama_prompt").Reference = &.{}, seed: u64, format: Format, stream: bool, budget_s: ?f64 = null }; -+const Plan = struct { input: []const u8, prompt: []const u32, frames: ?[]const [11]i32 = null, refs: []const @import("drama_prompt").Reference = &.{}, reference_rows: u32 = 0, seed: u64, format: Format, stream: bool, budget_s: ?f64 = null }; - - const Take = struct { pcm: []f32, reason: api.Reason, trip: bool = false, jump: ?f64 = null }; - -@@ -121,21 +121,22 @@ fn prepare(srv: *Server, cx: *Cx, sp: api.SpeechInfo, raw: Value) errors.Refused - return cx.fail(.request, drama_prompt.error_prefix ++ " unknown voice {s}; only the default voice is supported", .{name}); - }; - const encoded = try promptOf(srv, cx, sp, input, refs); -- return .{ .input = input, .prompt = encoded.prompt, .frames = encoded.frames, .refs = refs, .seed = api.seedFor(encoded.prompt, srv.config.seed_salt), .format = format, .stream = stream }; -+ return .{ .input = input, .prompt = encoded.prompt, .frames = encoded.frames, .refs = refs, .reference_rows = encoded.reference_rows, .seed = api.seedFor(encoded.prompt, srv.config.seed_salt), .format = format, .stream = stream }; - } - --fn promptOf(srv: *Server, cx: *Cx, sp: api.SpeechInfo, input: []const u8, refs: []const @import("drama_prompt").Reference) errors.Refused!struct { prompt: []const u32, frames: ?[]const [11]i32 = null } { -+fn promptOf(srv: *Server, cx: *Cx, sp: api.SpeechInfo, input: []const u8, refs: []const @import("drama_prompt").Reference) errors.Refused!struct { prompt: []const u32, frames: ?[]const [11]i32 = null, reference_rows: u32 = 0 } { - if (try drama_prompt.problem(cx.a, input)) |message| return cx.refuse(message); - if (refs.len > 0) { -- const frames = @import("drama_voice.zig").build(cx.a, srv.text, input, refs) catch |e| return switch (e) { -+ const built = @import("drama_voice.zig").build(cx.a, srv.text, input, refs) catch |e| return switch (e) { - error.OutOfMemory => error.OutOfMemory, - else => cx.other("the tokenizer cannot encode this voice prompt"), - }; -+ const frames = built.frames; - if (frames.len > sp.max_prompt_tokens) - return cx.fail(.request, drama_prompt.error_prefix ++ " a prompt of {d} rows exceeds the maximum allowed length of {d} rows", .{ frames.len, sp.max_prompt_tokens }); - const prompt = try cx.a.alloc(u32, frames.len); - for (frames, prompt) |row, *token| token.* = @intCast(row[0]); -- return .{ .prompt = prompt, .frames = frames }; -+ return .{ .prompt = prompt, .frames = frames, .reference_rows = built.reference_rows }; - } - const prompt = drama_prompt.encode(cx.a, srv.text, input) catch |e| return switch (e) { - error.OutOfMemory => error.OutOfMemory, -@@ -185,6 +186,7 @@ fn synthesize(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInf - .top_p = drama_prompt.top_p, - .top_k = drama_prompt.top_k, - .stream = plan.stream, -+ .reference_rows = plan.reference_rows, - }; - var box: Mailbox = .{ .io = srv.io, .gpa = srv.gpa }; - defer box.deinit(); -@@ -229,6 +231,7 @@ fn redraw(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, p - const encoded = promptOf(srv, cx, sp, input, plan.refs) catch |e| return unused(id, k, fallback, if (e == error.OutOfMemory) "out of memory" else cx.message); - next.prompt = encoded.prompt; - next.frames = encoded.frames orelse (rows(cx.a, next.prompt) catch |e| return unused(id, k, fallback, @errorName(e))); -+ next.reference_rows = encoded.reference_rows; - } - }; - } else { -diff --git a/zig/src/server/speech_stream_test.zig b/zig/src/server/speech_stream_test.zig -index 05ecd5d9..9ee6f225 100644 ---- a/zig/src/server/speech_stream_test.zig -+++ b/zig/src/server/speech_stream_test.zig -@@ -37,6 +37,7 @@ pub const Script = struct { - expected_rows: ?[]const [11]i32 = null, - rows_equal: bool = false, - seeds: [4]u64 = undefined, -+ reference_rows: [4]u32 = undefined, - takes: usize = 0, - - fn engine(e: *Script) api.Engine { -@@ -56,6 +57,7 @@ pub const Script = struct { - } - const take = e.takes; - e.seeds[take] = request.seed; -+ e.reference_rows[take] = request.reference_rows; - e.takes += 1; - if (e.inline_) return run(e, id, request, sink, take); - e.thread = std.Thread.spawn(.{}, run, .{ e, id, request, sink, take }) catch return error.Busy; -@@ -444,13 +446,14 @@ test "voices: HTTP listing and reference rows use the family CLI builder" { - } - }; - const expected = try family.build(a, CliTokenizer{}, "hi", &refs); -- var script: Script = .{ .steps = &.{frame}, .voices = &voices, .expected_rows = expected }; -+ var script: Script = .{ .steps = &.{frame}, .voices = &voices, .expected_rows = expected.frames }; - var s: Served = undefined; - try s.start(&script, "HTTP/1.1", "{\"model\":\"m\",\"input\":\"hi\",\"voice\":\"tone\",\"response_format\":\"pcm\"}", 2000); - defer s.deinit(); - try expectHeader(try s.readHead(), " 200 "); - s.join(&script); - try std.testing.expect(script.rows_equal); -+ try std.testing.expectEqual(expected.reference_rows, script.reference_rows[0]); - try std.testing.expectEqualStrings(&pcm(1), try s.readRest()); - var listing_script: Script = .{ .steps = &.{}, .voices = &voices, .get_voices = true }; - var listing: Served = undefined; -@@ -479,6 +482,7 @@ test "voices: without configuration the default prompt rows stay unchanged" { - try expectHeader(try s.readHead(), " 200 "); - s.join(&script); - try std.testing.expect(script.rows_equal); -+ try std.testing.expectEqual(@as(u32, 0), script.reference_rows[0]); - try std.testing.expectEqualStrings(&pcm(1), try s.readRest()); - } - --- -2.54.0 (Apple Git-157) - diff --git a/engine/patches/0013-speech-sampling-controls.patch b/engine/patches/0013-speech-sampling-controls.patch deleted file mode 100644 index fc0c1ce..0000000 --- a/engine/patches/0013-speech-sampling-controls.patch +++ /dev/null @@ -1,536 +0,0 @@ -From c712e5f9e73c298df8f3466b717f130ec4d824b4 Mon Sep 17 00:00:00 2001 -From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com> -Date: Sun, 25 Oct 2026 11:48:10 +0800 -Subject: [PATCH] server: expose per-request speech sampling controls - ---- - CHANGELOG.md | 2 + - README.md | 4 +- - docs/speech-redraws.md | 10 +- - docs/speech-sampling.md | 47 ++++++++ - zig/src/server/root.zig | 1 + - zig/src/server/speech.zig | 21 ++-- - zig/src/server/speech_register_test.zig | 28 +++++ - zig/src/server/speech_sampling.zig | 57 +++++++++ - zig/src/server/speech_sampling_test.zig | 140 ++++++++++++++++++++++ - zig/src/server/speech_stream_test.zig | 3 + - zig/src/server/speech_unmeasured_test.zig | 2 +- - 11 files changed, 301 insertions(+), 14 deletions(-) - create mode 100644 docs/speech-sampling.md - create mode 100644 zig/src/server/speech_sampling.zig - create mode 100644 zig/src/server/speech_sampling_test.zig - -diff --git a/CHANGELOG.md b/CHANGELOG.md -index 2f408d40..f75699c3 100644 ---- a/CHANGELOG.md -+++ b/CHANGELOG.md -@@ -4,6 +4,8 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel - - ## Unreleased - -+- Drama speech requests accept `temperature`, `top_p`, `top_k` (1 through 64) and exact unsigned 64-bit `seed` values. Whole replies, streams, catalog voices and redraws retain the requested settings; omitted or null fields keep the existing defaults. Explicit seeds advance by the attempt number for redraws. See [speech sampling](docs/speech-sampling.md). -+ - - Combined silence onset and mid settings append their clauses to the base startup sentence in onset-then-mid order; the mid clause includes its required voiced minimum. - - Drama 3 on CUDA prefills bounded row chunks in each shared round. `TF_DRAMA_PREFILL_ROWS` selects 1 through 1024 rows (default 128); the generation tool uses `--max-rows`. Batched projections and attention can change rounding relative to single-row prefill; oracle checks now compare the selected prompt chunks at their reference-row offsets. - - Drama 3 serving accepts `--max-frames` to bound synthesis frames, preserving the stock cap when omitted. -diff --git a/README.md b/README.md -index 06c0f3d3..ad7633de 100644 ---- a/README.md -+++ b/README.md -@@ -136,7 +136,7 @@ Conversions are explicitly marked: their upstream weights need the linked conver - | Step-5-Preview, converted affine | `hf download stepfun-ai/Step-5-Preview --local-dir ~/models/step5-preview-fp8` | One 256 GB Mac; text 166.9 GiB, MTP adds 2.5 GiB and vision 3.7 GiB. | [Convert and serve](docs/recipes/step-5-preview.md). The documented 256 GB setup serves converted affine weights; converting the upstream FP8 download needs Python and mlx. | - | Kolibri-1, block FP8 (research) | `tensorfold pull Aleph-Alpha/Kolibri-1@e52eb4627d11516b0c01de49210ab5a4e4061444` | One NVIDIA GPU; GB10 measured; 74 GB checkpoint. | [Kolibri](docs/recipes/kolibri-1.md). Text only; copy drafts, no EAGLE-3/neural drafter. | - | Liquid AI d1-3B, decisions | Not documented yet. | Native CUDA; RAM minimum not published. | [API section below](#serve-flags). `/v1/systemone` and `/v1/decisions`. | --| Fish Audio Drama 3, speech | Not documented yet. | Native Metal or CUDA; GB10 measured. | [Release audio instructions](CHANGELOG.md#110). `/v1/audio/speech`; checkpoint and `codec.pth` required. | -+| Fish Audio Drama 3, speech | Not documented yet. | Native Metal or CUDA; GB10 measured. | [Release audio instructions](CHANGELOG.md#110). `/v1/audio/speech` with [sampling controls](docs/speech-sampling.md); checkpoint and `codec.pth` required. | - - ### GLM-5.3: one machine or two? - -@@ -247,7 +247,7 @@ The binary's `capabilities --json` response lists its supported flags and platfo - | `--silence-seconds SECONDS` | Drama speech only: stop after this continuous duration below the explicit floor, retaining audio and reporting `silence`. Unset by default: off; calibrate above intentional pauses. | - | `--silence-onset-s SECONDS` | Drama speech only: with the silence guard on, stop once this many seconds run below its floor while the synthesis has voiced less than `--silence-onset-voiced-s`, retaining audio and reporting `silence`. Off unless given; must be below `--silence-seconds`. | - | `--silence-onset-voiced-s SECONDS` | Drama speech only: the voiced seconds below which `--silence-onset-s` applies; required with it, with no default. Calibrate on public or built-in speech. | --| `--silence-redraws N` | Drama speech only: draw a whole reply the silence guard ended again, up to N times, as seed salt + k; N of 1 or more needs the guard. Unset by default: off, as is 0; streams are never redrawn. See [speech redraws](docs/speech-redraws.md). | -+| `--silence-redraws N` | Drama speech only: draw a whole reply the silence guard ended again, up to N times, as seed salt + k or explicit request seed + k; N of 1 or more needs the guard. Unset by default: off, as is 0; streams are never redrawn. See [speech redraws](docs/speech-redraws.md). | - | `--silence-redraw-budget-s SECONDS` | Start a redraw only while elapsed time plus the last take's duration fits; a request's `X-Deadline-S` can lower it. No default budget. | - | `--silence-redraw-controls LIST` | Redraws speak the input filtered to the listed controls, as `--allowed-controls` filters; an empty list removes every control. | - | `--short-reply-ratio R` | Also redraw a whole reply that finished `stop` with less audio than R (above 0, below 1) of its text's expected length; needs `--silence-redraws` and `--short-reply-chars-per-s`. No default. | -diff --git a/docs/speech-redraws.md b/docs/speech-redraws.md -index b396fb8d..1a2c22a4 100644 ---- a/docs/speech-redraws.md -+++ b/docs/speech-redraws.md -@@ -12,12 +12,18 @@ needs it or the [register guard](#register). - - ## Seeds - --Redraw k (1 to N) seeds its prompt with `TENSORFOLD_SEED_SALT` plus k; the first -+Without an explicit request `seed`, redraw k (1 to N) seeds its prompt with `TENSORFOLD_SEED_SALT` plus k; the first - take keeps the stock seed, salt plus 0. Redraws are therefore deterministic. With - the same prompt, redraw k under salt s is the same draw as the first take under - salt s + k, so a sweep over salts should space compared salts more than N apart, - or N + 1 with the register guard. - -+An explicit request `seed` instead uses that exact integer for the first take -+and seed + k modulo 2^64 for each redraw, counting both silence and register -+attempts. Requested `temperature`, `top_p` and `top_k` remain in effect through -+all redraws and control filtering; see [speech sampling](speech-sampling.md). -+The seed rule does not make timing-based redraw budgets deterministic. -+ - ## Budget - - A redraw starts only when t + d fits the budget, where t is the time since the -@@ -125,7 +131,7 @@ below 8 kHz, all-zero audio, and takes with fewer than two retained phrases are - not measured. A measured take with no qualifying jump counts as zero. - The detector is not Praat's analysis: its values can differ from Praat's. - --A take that trips is drawn once more, as the next seed, salt + k after any -+A take that trips is drawn once more, using the next seed under the [seed rule](#seeds) after any - silence redraws, if t + d fits the budget. The redraw is sent only if it does not - trip silence or short and has a strictly smaller jump; ties retain the first take. - A `length` redraw can replace a `stop` take. An unmeasured redraw can win only -diff --git a/docs/speech-sampling.md b/docs/speech-sampling.md -new file mode 100644 -index 00000000..b60515c9 ---- /dev/null -+++ b/docs/speech-sampling.md -@@ -0,0 +1,47 @@ -+# Speech sampling -+ -+`POST /v1/audio/speech` accepts these optional fields for Drama 3. Omission or -+`null` keeps the existing default for that field. They apply to WAV replies, -+whole PCM replies, streamed PCM and catalog voices. -+ -+| Field | Accepted JSON value | Default | -+| --- | --- | --- | -+| `temperature` | A finite number at least 0, within the `f32` range. A positive value must remain positive when converted to `f32`. | `1` | -+| `top_p` | A finite number greater than 0 and at most 1, still greater than 0 after conversion to `f32`. | `0.9` | -+| `top_k` | An integer from 1 through 64. | `30` | -+| `seed` | An integer from 0 through 18446744073709551615. | The existing hash of the prompt and `TENSORFOLD_SEED_SALT`. | -+ -+Numbers encoded as strings and booleans are refused. `top_k` and `seed` must -+use integer JSON syntax: `30.0` and `1e3` are refused. Seeds are parsed as exact -+64-bit unsigned integers, including values above 9007199254740991; clients -+whose JSON number type loses precision must preserve the integer when encoding -+the request. Invalid fields return HTTP 400 before submission to the engine. -+ -+```sh -+curl http://127.0.0.1:8080/v1/audio/speech \ -+ -H 'Content-Type: application/json' \ -+ -d '{"model":"local-model","input":"Hello there.","temperature":0.8,"top_p":0.9,"top_k":30,"seed":42}' \ -+ --output speech.wav -+``` -+ -+The route passes these settings to the native sampler. On Metal and CUDA, `temperature` -+of 0 or `top_k` of 1 selects its existing greedy path, which bypasses repetition -+aware sampling (RAS). Other runtimes may handle repetition differently; matching -+fields and seeds do not promise identical audio across backends or checkpoints. -+The HTTP limit of 64 candidates fits both native Metal and CUDA admission. -+ -+An explicit `seed` is used exactly for the first take. If configured -+[speech redraws](speech-redraws.md) admit another take, its seed is the original -+seed plus the take's attempt number (1, 2, ...), wrapping modulo 2^64. This -+number includes both silence and register redraws. All takes keep the requested -+sampling values, including when controls are stripped or a reference prompt is -+rebuilt. With no seed, the existing prompt hash and server salt plus attempt -+number are unchanged. Streams keep their existing no-redraw behavior. -+ -+Seed selection is deterministic. The final take can still change if timing or -+request deadlines admit a different number of redraws. -+ -+The API design references the public -+[Sogni-AI/drama3-mlx generation interface](https://github.com/Sogni-AI/drama3-mlx/blob/ecc235039ccd5f982f66a6982184f57432354ab4/drama3_mlx/model.py#L79). -+TensorFold's parsing and request propagation are an independent implementation; -+this does not change the native sampling algorithms. -diff --git a/zig/src/server/root.zig b/zig/src/server/root.zig -index 3ff11101..94097cac 100644 ---- a/zig/src/server/root.zig -+++ b/zig/src/server/root.zig -@@ -43,6 +43,7 @@ test { - _ = @import("status_routes.zig"); - _ = @import("speech.zig"); - _ = @import("speech_stream_test.zig"); -+ _ = @import("speech_sampling_test.zig"); - _ = @import("speech_redraw.zig"); - _ = @import("speech_redraw_test.zig"); - _ = @import("speech_register_test.zig"); -diff --git a/zig/src/server/speech.zig b/zig/src/server/speech.zig -index 314b1180..0a5378d8 100644 ---- a/zig/src/server/speech.zig -+++ b/zig/src/server/speech.zig -@@ -9,6 +9,7 @@ const openai = @import("openai.zig"); - const pyrepr = @import("pyrepr.zig"); - const reply_text = @import("reply_text.zig"); - const drama_prompt = @import("drama_prompt.zig"); -+const speech_sampling = @import("speech_sampling.zig"); - const speech_redraw = @import("speech_redraw.zig"); - const speech_register = @import("speech_register_log.zig"); - const ids = @import("ids.zig"); -@@ -28,7 +29,7 @@ pub const max_input_chars = 4096; - const served_fields = [_][]const u8{ "model", "input", "voice", "speaker", "response_format" }; - - /// The rest of the speech protocol's fields: synthesis modifiers and references this route does not honor. --const modifier_fields = [_][]const u8{ "speed", "task_type", "language", "instructions", "ref_audio", "ref_text", "references", "x_vector_only_mode", "stream_codec_output", "suppress_bootstrap_silence", "token_count", "duration_tokens", "initial_codec_chunk_frames", "max_new_tokens", "temperature", "top_p", "top_k", "repetition_penalty", "seed", "stage_params" }; -+const modifier_fields = [_][]const u8{ "speed", "task_type", "language", "instructions", "ref_audio", "ref_text", "references", "x_vector_only_mode", "stream_codec_output", "suppress_bootstrap_silence", "token_count", "duration_tokens", "initial_codec_chunk_frames", "max_new_tokens", "repetition_penalty", "stage_params" }; - - pub const Format = enum { - wav, -@@ -42,7 +43,7 @@ pub const Format = enum { - } - }; - --const Plan = struct { input: []const u8, prompt: []const u32, frames: ?[]const [11]i32 = null, refs: []const @import("drama_prompt").Reference = &.{}, reference_rows: u32 = 0, seed: u64, format: Format, stream: bool, budget_s: ?f64 = null }; -+const Plan = struct { input: []const u8, prompt: []const u32, frames: ?[]const [11]i32 = null, refs: []const @import("drama_prompt").Reference = &.{}, reference_rows: u32 = 0, sampling: speech_sampling.Options, format: Format, stream: bool, budget_s: ?f64 = null }; - - const Take = struct { pcm: []f32, reason: api.Reason, trip: bool = false, jump: ?f64 = null }; - -@@ -84,6 +85,8 @@ fn checkFields(cx: *Cx, body: Value) errors.Refused!void { - if (value == .null or (value == .string and std.mem.eql(u8, value.string, "audio"))) continue; - if (value == .string and std.mem.eql(u8, value.string, "sse")) return cx.refuse("stream_format sse is not supported by this model"); - return cx.refuse("stream_format must be one of: audio, sse"); -+ } else if (speech_sampling.isField(key)) { -+ continue; // validated together before building the prompt - } else if (listed(&modifier_fields, key)) { - if (value != .null) return cx.fail(.request, "{s} is not supported by this model", .{key}); - } else return cx.fail(.request, "unknown speech request field: {s}", .{key}); -@@ -102,6 +105,7 @@ fn responseFormat(cx: *Cx, value: []const u8) errors.Refused!Format { - fn prepare(srv: *Server, cx: *Cx, sp: api.SpeechInfo, raw: Value) errors.Refused!Plan { - if (raw != .object) return cx.refuse("speech request body must be a JSON object"); - try checkFields(cx, raw); -+ const sampling = try speech_sampling.parse(cx, raw); - const model = raw.strField("model") orelse return cx.fail(.request, "model is required: send {s}", .{srv.config.served_name}); - if (!listed(srv.config.model_ids, model)) - return cx.fail(.request, "model {s} is not served here: send {s}", .{ try pyrepr.repr(cx.a, .{ .string = model }), srv.config.served_name }); -@@ -121,7 +125,7 @@ fn prepare(srv: *Server, cx: *Cx, sp: api.SpeechInfo, raw: Value) errors.Refused - return cx.fail(.request, drama_prompt.error_prefix ++ " unknown voice {s}; only the default voice is supported", .{name}); - }; - const encoded = try promptOf(srv, cx, sp, input, refs); -- return .{ .input = input, .prompt = encoded.prompt, .frames = encoded.frames, .refs = refs, .reference_rows = encoded.reference_rows, .seed = api.seedFor(encoded.prompt, srv.config.seed_salt), .format = format, .stream = stream }; -+ return .{ .input = input, .prompt = encoded.prompt, .frames = encoded.frames, .refs = refs, .reference_rows = encoded.reference_rows, .sampling = sampling, .format = format, .stream = stream }; - } - - fn promptOf(srv: *Server, cx: *Cx, sp: api.SpeechInfo, input: []const u8, refs: []const @import("drama_prompt").Reference) errors.Refused!struct { prompt: []const u32, frames: ?[]const [11]i32 = null, reference_rows: u32 = 0 } { -@@ -180,11 +184,11 @@ fn synthesize(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInf - .frames = frames, - .pcm = pcm, - .result = &result, -- .seed = plan.seed, -+ .seed = plan.sampling.seedFor(plan.prompt, srv.config.seed_salt, 0), - .max_frames = sp.max_frames, -- .temperature = drama_prompt.temperature, -- .top_p = drama_prompt.top_p, -- .top_k = drama_prompt.top_k, -+ .temperature = plan.sampling.temperature, -+ .top_p = plan.sampling.top_p, -+ .top_k = plan.sampling.top_k, - .stream = plan.stream, - .reference_rows = plan.reference_rows, - }; -@@ -253,7 +257,7 @@ fn redraw(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInfo, p - const preserved = if (held) |h| h.pcm.ptr else take.pcm.ptr; - next.pcm = if (preserved == first.pcm.ptr) spare.* else first.pcm; - next.result = &result; -- next.seed = api.seedFor(next.prompt, srv.config.seed_salt +% @as(i64, @intCast(k))); -+ next.seed = plan.sampling.seedFor(next.prompt, srv.config.seed_salt, k); - var box: Mailbox = .{ .io = srv.io, .gpa = srv.gpa }; - defer box.deinit(); - const engine_id = srv.next_id.fetchAdd(1, .monotonic); -@@ -555,7 +559,6 @@ test "fields: unknown ones are refused even when null, modifiers when set" { - .{ "{\"model\": \"m\", \"input\": \"hi\", \"stream\": \"yes\"}", "stream must be a boolean" }, - .{ "{\"model\": \"m\", \"input\": \"hi\", \"stream_format\": \"sse\"}", "stream_format sse is not supported by this model" }, - .{ "{\"model\": \"m\", \"input\": \"hi\", \"stream_format\": \"mp3\"}", "stream_format must be one of: audio, sse" }, -- .{ "{\"model\": \"m\", \"input\": \"hi\", \"temperature\": 0.7}", "temperature is not supported by this model" }, - .{ "{\"model\": \"m\", \"input\": \"hi\", \"ref_audio\": \"x.wav\"}", "ref_audio is not supported by this model" }, - .{ "{\"model\": \"m\", \"input\": 3}", "input must be a string" }, - .{ "{\"model\": null, \"input\": \"hi\"}", "model must be a string" }, -diff --git a/zig/src/server/speech_register_test.zig b/zig/src/server/speech_register_test.zig -index 38018541..aa613443 100644 ---- a/zig/src/server/speech_register_test.zig -+++ b/zig/src/server/speech_register_test.zig -@@ -166,6 +166,34 @@ test "the register guard measures the take silence redraws leave, and off sends - try expectSent(a, &.{ high, flat }, &.{}, .{}, "X-Deadline-S: soon\r\n", 0, 1); - } - -+test "speech sampling: silence then register redraws retain parameters and advance the explicit seed" { -+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); -+ defer arena.deinit(); -+ const a = arena.allocator(); -+ const high = try tones(a, 44100, &reply(130, 330)); -+ const flat = try tones(a, 44100, &reply(130, 127)); -+ const n = [_]usize{flat.len}; -+ var script: harness.Script = .{ .steps = &n, .audio = &.{ flat, high, flat }, .reasons = &.{ .silence, .stop, .stop }, .max_frames = @intCast(n[0] / harness.frame + 1), .inline_ = true }; -+ var s: harness.Served = undefined; -+ const cfg: @import("root.zig").Config = .{ .served_name = "m", .model_ids = &.{"m"}, .redraw = .{ .max = 1, .register = rule } }; -+ const body = "{\"model\":\"m\",\"input\":\"hi\",\"response_format\":\"pcm\",\"temperature\":0.5,\"top_p\":0.75,\"top_k\":8,\"seed\":9007199254740993}"; -+ try s.open(&script, cfg, "", "HTTP/1.1", body, 2000); -+ defer s.deinit(); -+ { -+ defer s.join(&script); -+ _ = try s.readHead(); -+ _ = try s.readRest(); -+ } -+ try harness.expectHeader(s.got.items, "HTTP/1.1 200"); -+ try harness.expectHeader(s.got.items, "X-Silence-Redraws: 1"); -+ try harness.expectHeader(s.got.items, "X-Register-Redraws: 1"); -+ try std.testing.expectEqual(@as(usize, 3), script.takes); -+ for (0..script.takes) |k| { -+ try std.testing.expectEqual(@as(u64, 9007199254740993) + k, script.seeds[k]); -+ try std.testing.expectEqualDeep(harness.Sampling{ .temperature = 0.5, .top_p = 0.75, .top_k = 8 }, script.sampling[k]); -+ } -+} -+ - test "register metadata counts admission not selection and latches trips even without budget" { - var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); - defer arena.deinit(); -diff --git a/zig/src/server/speech_sampling.zig b/zig/src/server/speech_sampling.zig -new file mode 100644 -index 00000000..ef0d7777 ---- /dev/null -+++ b/zig/src/server/speech_sampling.zig -@@ -0,0 +1,57 @@ -+//! Request-local Drama sampling, checked before a speech request reaches the engine. -+const std = @import("std"); -+const api = @import("engine_api"); -+const Value = @import("json").Value; -+const errors = @import("errors.zig"); -+const defaults = @import("drama_prompt.zig"); -+ -+pub const Options = struct { -+ temperature: f32 = defaults.temperature, -+ top_p: f32 = defaults.top_p, -+ top_k: u32 = defaults.top_k, -+ seed: ?u64 = null, -+ -+ pub fn seedFor(o: Options, prompt: []const u32, salt: i64, attempt: usize) u64 { -+ if (o.seed) |seed| return seed +% @as(u64, @intCast(attempt)); -+ return api.seedFor(prompt, salt +% @as(i64, @intCast(attempt))); -+ } -+}; -+ -+pub fn isField(key: []const u8) bool { -+ for ([_][]const u8{ "temperature", "top_p", "top_k", "seed" }) |name| -+ if (std.mem.eql(u8, key, name)) return true; -+ return false; -+} -+ -+pub fn parse(cx: *errors.Cx, body: Value) errors.Refused!Options { -+ var out: Options = .{}; -+ if (body.field("temperature")) |value| -+ out.temperature = try number(cx, value, std.math.floatMax(f32), "temperature must be a finite nonnegative f32 number"); -+ if (body.field("top_p")) |value| { -+ out.top_p = try number(cx, value, 1, "top_p must be a finite f32 number greater than 0 and at most 1"); -+ if (out.top_p == 0) return cx.refuse("top_p must be greater than 0"); -+ } -+ if (body.field("top_k")) |value| { -+ const message = "top_k must be an integer from 1 through 64"; -+ if (value != .int) return cx.refuse(message); -+ const n = std.fmt.parseInt(u32, value.int, 10) catch return cx.refuse(message); -+ if (n == 0 or n > 64) return cx.refuse(message); -+ out.top_k = n; -+ } -+ if (body.field("seed")) |value| { -+ const message = "seed must be an integer from 0 through 18446744073709551615"; -+ if (value != .int) return cx.refuse(message); -+ out.seed = std.fmt.parseInt(u64, value.int, 10) catch return cx.refuse(message); -+ } -+ return out; -+} -+ -+/// Check the JSON range before narrowing, then reject positive values that would become zero. -+fn number(cx: *errors.Cx, value: Value, max: f64, message: []const u8) errors.Refused!f32 { -+ if (value != .int and value != .float) return cx.refuse(message); -+ const n = value.float64() orelse return cx.refuse(message); -+ if (!std.math.isFinite(n) or n < 0 or n > max) return cx.refuse(message); -+ const narrowed: f32 = @floatCast(n); -+ if (n > 0 and narrowed == 0) return cx.refuse(message); -+ return narrowed; -+} -diff --git a/zig/src/server/speech_sampling_test.zig b/zig/src/server/speech_sampling_test.zig -new file mode 100644 -index 00000000..7d7b3212 ---- /dev/null -+++ b/zig/src/server/speech_sampling_test.zig -@@ -0,0 +1,140 @@ -+//! Speech sampling controls through the HTTP route and its scripted engine. -+const std = @import("std"); -+const api = @import("engine_api"); -+const server = @import("root.zig"); -+const harness = @import("speech_stream_test.zig"); -+const drama_prompt = @import("drama_prompt.zig"); -+const drama_allow = @import("drama_allow.zig"); -+const gpa = std.testing.allocator; -+const config: server.Config = .{ .served_name = "m", .model_ids = &.{"m"}, .seed_salt = 17 }; -+const defaults: harness.Sampling = .{ .temperature = 1, .top_p = 0.9, .top_k = 30 }; -+ -+fn exchange(script: *harness.Script, cfg: server.Config, body: []const u8) ![]u8 { -+ var s: harness.Served = undefined; -+ try s.open(script, cfg, "", "HTTP/1.1", body, 2000); -+ defer s.deinit(); -+ defer s.join(script); -+ _ = try s.readHead(); -+ _ = try s.readRest(); -+ return gpa.dupe(u8, s.got.items); -+} -+ -+fn promptSeed(text: []const u8, attempt: i64) !u64 { -+ var arena: std.heap.ArenaAllocator = .init(gpa); -+ defer arena.deinit(); -+ var bytes: harness.Bytes = .{}; -+ const prompt = try drama_prompt.encode(arena.allocator(), bytes.text(), text); -+ return api.seedFor(prompt, config.seed_salt +% attempt); -+} -+ -+fn expectDraw(script: *const harness.Script, index: usize, sampling: harness.Sampling, seed: u64) !void { -+ try std.testing.expectEqualDeep(sampling, script.sampling[index]); -+ try std.testing.expectEqual(seed, script.seeds[index]); -+} -+ -+test "speech sampling: omitted and null controls retain default parameters and PCM" { -+ const bodies = [_][]const u8{ -+ "{\"model\":\"m\",\"input\":\"hi\",\"response_format\":\"pcm\"}", -+ "{\"model\":\"m\",\"input\":\"hi\",\"response_format\":\"pcm\",\"temperature\":null,\"top_p\":null,\"top_k\":null,\"seed\":null}", -+ }; -+ var first: ?[]u8 = null; -+ defer if (first) |pcm| gpa.free(pcm); -+ for (bodies) |body| { -+ var script: harness.Script = .{ .steps = &.{harness.frame}, .inline_ = true }; -+ const reply = try exchange(&script, config, body); -+ defer gpa.free(reply); -+ try harness.expectHeader(reply, "HTTP/1.1 200"); -+ try std.testing.expectEqual(@as(usize, 1), script.takes); -+ try expectDraw(&script, 0, defaults, try promptSeed("hi", 0)); -+ const pcm = reply[(std.mem.indexOf(u8, reply, "\r\n\r\n").? + 4)..]; -+ if (first) |before| try std.testing.expectEqualSlices(u8, before, pcm) else first = try gpa.dupe(u8, pcm); -+ } -+} -+ -+test "speech sampling: custom parameters and exact integer seeds reach whole and streamed requests" { -+ const cases = [_]struct { fields: []const u8, sampling: harness.Sampling, seed: u64 }{ -+ .{ .fields = "\"temperature\":1.25,\"top_p\":0.8,\"top_k\":64,\"seed\":9007199254740993", .sampling = .{ .temperature = 1.25, .top_p = 0.8, .top_k = 64 }, .seed = 9007199254740993 }, -+ .{ .fields = "\"temperature\":0,\"top_p\":1,\"top_k\":1,\"seed\":0", .sampling = .{ .temperature = 0, .top_p = 1, .top_k = 1 }, .seed = 0 }, -+ .{ .fields = "\"seed\":18446744073709551615", .sampling = defaults, .seed = std.math.maxInt(u64) }, -+ .{ .fields = "\"temperature\":0.5", .sampling = .{ .temperature = 0.5, .top_p = 0.9, .top_k = 30 }, .seed = try promptSeed("hi", 0) }, -+ .{ .fields = "\"temperature\":3.4028234663852886e38,\"top_p\":1e-45", .sampling = .{ .temperature = std.math.floatMax(f32), .top_p = 1e-45, .top_k = 30 }, .seed = try promptSeed("hi", 0) }, -+ }; -+ for (cases) |case| for ([_]bool{ false, true }) |stream| { -+ const body = try std.fmt.allocPrint(gpa, "{{\"model\":\"m\",\"input\":\"hi\",\"response_format\":\"pcm\",\"stream\":{s},{s}}}", .{ if (stream) "true" else "false", case.fields }); -+ defer gpa.free(body); -+ var script: harness.Script = .{ .steps = &.{harness.frame}, .inline_ = true }; -+ const reply = try exchange(&script, config, body); -+ defer gpa.free(reply); -+ try harness.expectHeader(reply, "HTTP/1.1 200"); -+ if (stream) try harness.expectHeader(reply, "Transfer-Encoding: chunked"); -+ try std.testing.expectEqual(@as(usize, 1), script.takes); -+ try expectDraw(&script, 0, case.sampling, case.seed); -+ }; -+} -+ -+test "speech sampling: invalid controls fail before any engine submission" { -+ const fields = [_][]const u8{ -+ "\"temperature\":true", "\"temperature\":\"1\"", "\"temperature\":[]", "\"temperature\":{}", -+ "\"temperature\":-1", "\"temperature\":NaN", "\"temperature\":Infinity", "\"temperature\":1e39", -+ "\"temperature\":1e-100", "\"top_p\":false", "\"top_p\":\"0.9\"", "\"top_p\":0", -+ "\"top_p\":-0.1", "\"top_p\":1.00000001", "\"top_p\":NaN", "\"top_p\":1e-100", -+ "\"top_p\":Infinity", "\"top_k\":true", "\"top_k\":\"30\"", "\"top_k\":1.5", -+ "\"top_k\":30.0", "\"top_k\":0", "\"top_k\":-1", "\"top_k\":65", -+ "\"top_k\":18446744073709551616", "\"seed\":true", "\"seed\":\"1\"", "\"seed\":1.5", -+ "\"seed\":1.0", "\"seed\":-1", "\"seed\":18446744073709551616", "\"seed\":NaN", -+ }; -+ for (fields) |field| { -+ const body = try std.fmt.allocPrint(gpa, "{{\"model\":\"m\",\"input\":\"hi\",{s}}}", .{field}); -+ defer gpa.free(body); -+ var script: harness.Script = .{ .steps = &.{harness.frame}, .inline_ = true }; -+ const reply = try exchange(&script, config, body); -+ defer gpa.free(reply); -+ try harness.expectHeader(reply, "HTTP/1.1 400"); -+ try std.testing.expectEqual(@as(usize, 0), script.takes); -+ } -+} -+ -+test "speech sampling: references and re-prompts retain overrides and wrap explicit retry seeds" { -+ const codes = [_][10]i32{.{ 7, 1, 2, 3, 4, 5, 6, 7, 8, 9 }}; -+ const refs = [_]@import("drama_prompt").Reference{.{ .transcript = "A small cloud passes.", .codes = &codes }}; -+ const voices = [_]@import("drama_voice.zig").Voice{.{ .name = "tone", .references = &refs }}; -+ var cfg = config; -+ cfg.redraw = .{ .max = 2, .controls = try drama_allow.parse("sighs") }; -+ for ([_]bool{ false, true }) |referenced| { -+ var script: harness.Script = .{ .steps = &.{harness.frame}, .inline_ = true, .reasons = &.{ .silence, .silence, .stop }, .voices = &voices }; -+ const body = try std.fmt.allocPrint(gpa, "{{\"model\":\"m\",\"input\":\"hi [sighs] [laughs] there\",\"voice\":\"{s}\",\"temperature\":0.5,\"top_p\":0.75,\"top_k\":8,\"seed\":18446744073709551615}}", .{if (referenced) "tone" else "default"}); -+ defer gpa.free(body); -+ const reply = try exchange(&script, cfg, body); -+ defer gpa.free(reply); -+ try harness.expectHeader(reply, "HTTP/1.1 200"); -+ try std.testing.expectEqual(@as(usize, 3), script.takes); -+ for ([_]u64{ std.math.maxInt(u64), 0, 1 }, 0..) |seed, k| -+ try expectDraw(&script, k, .{ .temperature = 0.5, .top_p = 0.75, .top_k = 8 }, seed); -+ try std.testing.expectEqual(referenced, script.reference_rows[0] > 0); -+ } -+} -+ -+test "speech sampling: no seed keeps prompt-keyed redraws after stripping controls" { -+ var script: harness.Script = .{ .steps = &.{harness.frame}, .inline_ = true, .reasons = &.{ .silence, .stop } }; -+ var cfg = config; -+ cfg.redraw = .{ .max = 1, .controls = try drama_allow.parse("sighs") }; -+ const reply = try exchange(&script, cfg, "{\"model\":\"m\",\"input\":\"hi [sighs] [laughs] there\",\"temperature\":0.5}"); -+ defer gpa.free(reply); -+ try harness.expectHeader(reply, "HTTP/1.1 200"); -+ try std.testing.expectEqual(@as(usize, 2), script.takes); -+ const sampling: harness.Sampling = .{ .temperature = 0.5, .top_p = 0.9, .top_k = 30 }; -+ try expectDraw(&script, 0, sampling, try promptSeed("hi [sighs] [laughs] there", 0)); -+ try expectDraw(&script, 1, sampling, try promptSeed("hi [sighs] there", 1)); -+} -+ -+test "speech sampling: a silenced stream retains controls and does not redraw" { -+ var script: harness.Script = .{ .steps = &.{harness.frame}, .inline_ = true, .reason = .silence }; -+ var cfg = config; -+ cfg.redraw = .{ .max = 2 }; -+ const reply = try exchange(&script, cfg, "{\"model\":\"m\",\"input\":\"hi\",\"response_format\":\"pcm\",\"stream\":true,\"temperature\":0.5,\"seed\":2}"); -+ defer gpa.free(reply); -+ try harness.expectHeader(reply, "HTTP/1.1 200"); -+ try harness.expectHeader(reply, "X-Finish-Reason: silence"); -+ try std.testing.expectEqual(@as(usize, 1), script.takes); -+ try expectDraw(&script, 0, .{ .temperature = 0.5, .top_p = 0.9, .top_k = 30 }, 2); -+} -diff --git a/zig/src/server/speech_stream_test.zig b/zig/src/server/speech_stream_test.zig -index 9ee6f225..84467565 100644 ---- a/zig/src/server/speech_stream_test.zig -+++ b/zig/src/server/speech_stream_test.zig -@@ -9,6 +9,7 @@ const Conn = @import("http_conn.zig").Conn; - - pub const frame = 2048; - const gpa = std.testing.allocator; -+pub const Sampling = struct { temperature: f32, top_p: f32, top_k: u32 }; - - /// Publishes steps of 0.5 / (take + 1), then finishes: inside submit (`inline_`), else on a thread that may `hold`. - pub const Script = struct { -@@ -37,6 +38,7 @@ pub const Script = struct { - expected_rows: ?[]const [11]i32 = null, - rows_equal: bool = false, - seeds: [4]u64 = undefined, -+ sampling: [4]Sampling = undefined, - reference_rows: [4]u32 = undefined, - takes: usize = 0, - -@@ -57,6 +59,7 @@ pub const Script = struct { - } - const take = e.takes; - e.seeds[take] = request.seed; -+ e.sampling[take] = .{ .temperature = request.temperature, .top_p = request.top_p, .top_k = request.top_k }; - e.reference_rows[take] = request.reference_rows; - e.takes += 1; - if (e.inline_) return run(e, id, request, sink, take); -diff --git a/zig/src/server/speech_unmeasured_test.zig b/zig/src/server/speech_unmeasured_test.zig -index 0ffbd52c..23a47807 100644 ---- a/zig/src/server/speech_unmeasured_test.zig -+++ b/zig/src/server/speech_unmeasured_test.zig -@@ -29,7 +29,7 @@ pub fn check(comptime redraw: anytype) !void { - capture.len = 0; - var pcm = [_]f32{sample}; - var metadata: Metadata = .{ .enabled = false, .register_on = true }; -- const take = redraw(&srv, &conn, &cx, "register-test", .{ .sample_rate = 44100, .frame_length = 2048, .max_frames = 1024, .max_prompt_tokens = 4096 }, .{ .input = "hi", .prompt = &.{}, .seed = 0, .format = .pcm, .stream = false }, 0, undefined, 0, .{ .pcm = &pcm, .reason = .stop }, &spare, &metadata).?; -+ const take = redraw(&srv, &conn, &cx, "register-test", .{ .sample_rate = 44100, .frame_length = 2048, .max_frames = 1024, .max_prompt_tokens = 4096 }, .{ .input = "hi", .prompt = &.{}, .sampling = .{ .seed = 0 }, .format = .pcm, .stream = false }, 0, undefined, 0, .{ .pcm = &pcm, .reason = .stop }, &spare, &metadata).?; - try std.testing.expect(take.jump == null); - try std.testing.expect(take.pcm.ptr == &pcm); - try std.testing.expect(!metadata.register_tripped and !metadata.register_redrawn); --- -2.46.0 - diff --git a/engine/patches/0014-sampling-json-boundaries.patch b/engine/patches/0014-sampling-json-boundaries.patch deleted file mode 100644 index 8af3d25..0000000 --- a/engine/patches/0014-sampling-json-boundaries.patch +++ /dev/null @@ -1,90 +0,0 @@ -From 1c31b4d2567b6bbca7ac1b394f17566ca3c05488 Mon Sep 17 00:00:00 2001 -From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com> -Date: Sun, 25 Oct 2026 11:54:45 +0800 -Subject: [PATCH] tests: cover parsed JSON sampling float boundaries - ---- - docs/speech-sampling.md | 7 +++++++ - zig/src/server/speech_sampling.zig | 2 +- - zig/src/server/speech_sampling_test.zig | 27 +++++++++++++++++-------- - 3 files changed, 27 insertions(+), 9 deletions(-) - -diff --git a/docs/speech-sampling.md b/docs/speech-sampling.md -index b60515c9..29f6474e 100644 ---- a/docs/speech-sampling.md -+++ b/docs/speech-sampling.md -@@ -17,6 +17,13 @@ use integer JSON syntax: `30.0` and `1e3` are refused. Seeds are parsed as exact - whose JSON number type loses precision must preserve the integer when encoding - the request. Invalid fields return HTTP 400 before submission to the engine. - -+For `temperature` and `top_p`, the JSON parser first produces an `f64` value; -+the bounds above apply to that value and its conversion to `f32`. Extremely -+small floating literals such as `1e-400` and `-1e-400` already become zero in -+that parser, so both request temperature 0; they are refused for `top_p`. -+A positive parsed value such as `1e-100` is refused if it becomes zero only -+when narrowed to `f32`. -+ - ```sh - curl http://127.0.0.1:8080/v1/audio/speech \ - -H 'Content-Type: application/json' \ -diff --git a/zig/src/server/speech_sampling.zig b/zig/src/server/speech_sampling.zig -index ef0d7777..ca9b5320 100644 ---- a/zig/src/server/speech_sampling.zig -+++ b/zig/src/server/speech_sampling.zig -@@ -46,7 +46,7 @@ pub fn parse(cx: *errors.Cx, body: Value) errors.Refused!Options { - return out; - } - --/// Check the JSON range before narrowing, then reject positive values that would become zero. -+/// Check the parsed f64 range before narrowing, then reject positive values that would become zero. - fn number(cx: *errors.Cx, value: Value, max: f64, message: []const u8) errors.Refused!f32 { - if (value != .int and value != .float) return cx.refuse(message); - const n = value.float64() orelse return cx.refuse(message); -diff --git a/zig/src/server/speech_sampling_test.zig b/zig/src/server/speech_sampling_test.zig -index 7d7b3212..8029a75d 100644 ---- a/zig/src/server/speech_sampling_test.zig -+++ b/zig/src/server/speech_sampling_test.zig -@@ -57,6 +57,8 @@ test "speech sampling: custom parameters and exact integer seeds reach whole and - .{ .fields = "\"temperature\":0,\"top_p\":1,\"top_k\":1,\"seed\":0", .sampling = .{ .temperature = 0, .top_p = 1, .top_k = 1 }, .seed = 0 }, - .{ .fields = "\"seed\":18446744073709551615", .sampling = defaults, .seed = std.math.maxInt(u64) }, - .{ .fields = "\"temperature\":0.5", .sampling = .{ .temperature = 0.5, .top_p = 0.9, .top_k = 30 }, .seed = try promptSeed("hi", 0) }, -+ .{ .fields = "\"temperature\":1e-400", .sampling = .{ .temperature = 0, .top_p = 0.9, .top_k = 30 }, .seed = try promptSeed("hi", 0) }, -+ .{ .fields = "\"temperature\":-1e-400", .sampling = .{ .temperature = -0.0, .top_p = 0.9, .top_k = 30 }, .seed = try promptSeed("hi", 0) }, - .{ .fields = "\"temperature\":3.4028234663852886e38,\"top_p\":1e-45", .sampling = .{ .temperature = std.math.floatMax(f32), .top_p = 1e-45, .top_k = 30 }, .seed = try promptSeed("hi", 0) }, - }; - for (cases) |case| for ([_]bool{ false, true }) |stream| { -@@ -74,14 +76,23 @@ test "speech sampling: custom parameters and exact integer seeds reach whole and - - test "speech sampling: invalid controls fail before any engine submission" { - const fields = [_][]const u8{ -- "\"temperature\":true", "\"temperature\":\"1\"", "\"temperature\":[]", "\"temperature\":{}", -- "\"temperature\":-1", "\"temperature\":NaN", "\"temperature\":Infinity", "\"temperature\":1e39", -- "\"temperature\":1e-100", "\"top_p\":false", "\"top_p\":\"0.9\"", "\"top_p\":0", -- "\"top_p\":-0.1", "\"top_p\":1.00000001", "\"top_p\":NaN", "\"top_p\":1e-100", -- "\"top_p\":Infinity", "\"top_k\":true", "\"top_k\":\"30\"", "\"top_k\":1.5", -- "\"top_k\":30.0", "\"top_k\":0", "\"top_k\":-1", "\"top_k\":65", -- "\"top_k\":18446744073709551616", "\"seed\":true", "\"seed\":\"1\"", "\"seed\":1.5", -- "\"seed\":1.0", "\"seed\":-1", "\"seed\":18446744073709551616", "\"seed\":NaN", -+ "\"top_p\":1e-400", "\"top_p\":-1e-400", -+ "\"temperature\":true", "\"temperature\":\"1\"", -+ "\"temperature\":[]", "\"temperature\":{}", -+ "\"temperature\":-1", "\"temperature\":NaN", -+ "\"temperature\":Infinity", "\"temperature\":1e39", -+ "\"temperature\":1e-100", "\"top_p\":false", -+ "\"top_p\":\"0.9\"", "\"top_p\":0", -+ "\"top_p\":-0.1", "\"top_p\":1.00000001", -+ "\"top_p\":NaN", "\"top_p\":1e-100", -+ "\"top_p\":Infinity", "\"top_k\":true", -+ "\"top_k\":\"30\"", "\"top_k\":1.5", -+ "\"top_k\":30.0", "\"top_k\":0", -+ "\"top_k\":-1", "\"top_k\":65", -+ "\"top_k\":18446744073709551616", "\"seed\":true", -+ "\"seed\":\"1\"", "\"seed\":1.5", -+ "\"seed\":1.0", "\"seed\":-1", -+ "\"seed\":18446744073709551616", "\"seed\":NaN", - }; - for (fields) |field| { - const body = try std.fmt.allocPrint(gpa, "{{\"model\":\"m\",\"input\":\"hi\",{s}}}", .{field}); --- -2.46.0 - diff --git a/engine/patches/README.md b/engine/patches/README.md index fb717c8..b3d82e6 100644 --- a/engine/patches/README.md +++ b/engine/patches/README.md @@ -1,30 +1,21 @@ # Engine patch series -The `*.patch` files here are opt-in patches made with `git format-patch` against the upstream TensorFold commit -pinned in [`../UPSTREAM`](../UPSTREAM). `scripts/build-engine.sh` applies them in file-name order with `git am`. -Each one is a feature branch's net diff over the patches before it; the list, the flag each adds and the upstream -pull request that carries it are in the [README](../../README.md#the-patches). +The full-model series is empty: TensorFold v1.1.9, pinned in [`../UPSTREAM`](../UPSTREAM), carries every +feature the old patches 0001–0014 added, so `scripts/build-engine.sh --weights full` builds the release as is. +The empty `SHA256SUMS` declares that empty series; deleting it, or listing a patch that is not here, fails the +build before checkout. The features and their upstream pull requests are in the +[README](../../README.md#full-model-engine). -All patches are Apache-2.0 ([`../../LICENSE`](../../LICENSE)). The Drama 3 model weights and codec checkpoint the -engine loads are under the Fish Audio Community License v1.0 and are downloaded separately; see -[`NOTICE`](../../NOTICE). +New patches are made with `git format-patch` against the pinned commit, start again at 0001 and are applied in +file-name order with `git am`. All patches are Apache-2.0 ([`../../LICENSE`](../../LICENSE)). The Drama 3 model +weights and codec checkpoint the engine loads are under the Fish Audio Community License v1.0 and are downloaded +separately; see [`NOTICE`](../../NOTICE). `SHA256SUMS` pins every patch. Linux `--weights nvfp4` uses the separate [CUDA NVFP4 series](../nvfp4/patches/README.md). All profiles require an exact manifest-to-patch match before applying anything. -Full-model patches 0013/0014 add [request sampling controls](../../recipe/README-sampling.md) -and document the existing JSON floating-point boundary. They backport -[TensorFold #1277](https://github.com/ashhart/TensorFold/pull/1277) without moving the pin. - -Patch 0012 is corrected in place: it owns one reusable Metal recurrent-state snapshot per lane, -invalidates incompatible live KV before prefill, and uses the prompt builder's exact invariant -boundary including tower lookahead. It preserves uncached chunk cuts and CUDA execution. -Its CPU regressions cover identity, cancellation/error invalidation, allocation ownership and -prompt boundaries; real-model transition/corpus/stream checks are described in -[`recipe/README-macos.md`](../../recipe/README-macos.md#correctness-gates). - ## Native macOS NVFP4 series On Darwin, `--weights nvfp4` uses the separate pin in diff --git a/engine/patches/SHA256SUMS b/engine/patches/SHA256SUMS index fe92a6c..e69de29 100644 --- a/engine/patches/SHA256SUMS +++ b/engine/patches/SHA256SUMS @@ -1,14 +0,0 @@ -cd0f6b14d7b50eb1a336bc00a9b879caf4fca56f3df1082f3ff2bebfc1284402 0001-feat-drama3-batch-CUDA-prompt-prefill-in-bounded-rou.patch -1ab86a5ca409374a3096e36b0ef6e24a092d14ab15d027fff4d7adc4ab6bc915 0002-fix-drama3-complete-replay-poison-before-resetting-s.patch -1f9bc1708d34e75885e84be466b87e7a7f381694cd74ac2463197b99717217fa 0003-feat-speech-configure-the-Drama-frame-budget.patch -87dcc434578539d34751a4751ebb104b1cfd6d32a2d441c5c129d6d0b78da7b6 0004-feat-server-filter-speech-controls-with-an-opt-in-al.patch -b438ddbc899ed1aea6a493bece3f8194ef3ffaab422f57157012e7008ff8b792 0005-feat-server-serve-attested-Drama-voice-catalogs.patch -ce66052a66f3a5a96959948742faaf60275053e91027672ae8e76bc384b4bb5f 0006-feat-speech-add-opt-in-sustained-silence-guard.patch -38da31d044b6beb7b9d905bf07ca76735e74e5596a1bd22e1f3bc7e3fe440e9d 0007-feat-speech-add-opt-in-silence-onset-rule-to-the-sil.patch -a1289601a1aeccd57354320f55d6563108d40a5a048596760f8498b0cc375773 0008-feat-speech-add-opt-in-silence-mid-s-lost-ending-tri.patch -53234cfe2699f9962c800dc9e60fd162a681599b8b9bf2b4762016c209cd037e 0009-feat-speech-redraw-whole-replies-the-silence-guard-e.patch -db1a8e4bdf817ab7d92e4635453075d8ea31a0d40537dcda8ccc9bec456a69cf 0010-feat-speech-redraw-whole-replies-that-stopped-short-.patch -0bb2318e54fe7df3355b981b08c4098b3185c58a955366dccf530f93e21a11a8 0011-feat-speech-redraw-a-whole-reply-once-when-a-phrase-.patch -562b63cbae92fe004ad1ab07ea791e1f3b212c2d7d71df40898257df7ad829f8 0012-feat-drama3-reuse-a-voice-reference-s-prefill-across.patch -b686340bc1fc0bccabdf6089bcea697ac4fee7741829fb6ffb49b3d0f15b9ab7 0013-speech-sampling-controls.patch -3e0855a714dc5b705bed956937a9d0eac272f76a0d03021243e7aab5d1c8f753 0014-sampling-json-boundaries.patch diff --git a/recipe/README-macos.md b/recipe/README-macos.md index fa28724..fe1c1f1 100644 --- a/recipe/README-macos.md +++ b/recipe/README-macos.md @@ -1,7 +1,7 @@ # macOS: running the pinned recipe on Apple silicon Both `--weights full` and `--weights nvfp4` use the native Zig/Metal engine, not Python/MLX. -The full model retains the Linux recipe's existing pin and patches. NVFP4 uses the separate +The full model uses the Linux recipe's pin, TensorFold v1.1.9, with no patches. NVFP4 uses the separate [Mac engine pin](../engine/nvfp4/macos/UPSTREAM) and patch series; the CUDA NVFP4 pin is unchanged. Neither path requires CUDA, `nvcc`, systemd, or `/proc`. Use `recipe/pilot_macos.py`. @@ -180,7 +180,7 @@ Start a new coordinated pilot with `-- --voices /absolute/path/to/runtime/voices directory. `GET /v1/voices` lists loaded voices. Without `--default-voice`, omitted/default requests use the builtin voice. Setting `--default-voice NAME` changes both cases. -The full-model patch 0012 and the NVFP4 Mac base reuse one conditional reference-prefix snapshot per +The full-model engine (upstream since TensorFold v1.1.5) and the NVFP4 Mac base reuse one conditional reference-prefix snapshot per Metal lane. A different or absent prefix invalidates it before prefill; switching voices reuses the allocated snapshot buffers. Guided requests reset their independent unconditional backbone state; cancellation invalidates conditional retention before another request. Neither is a multi-voice cache. diff --git a/recipe/README-sampling.md b/recipe/README-sampling.md index 6ccb238..cd9243f 100644 --- a/recipe/README-sampling.md +++ b/recipe/README-sampling.md @@ -49,8 +49,9 @@ use their native greedy path for zero temperature or `top_k: 1`; the legacy CUDA profile retains its existing repetition-aware sampler. The public [Sogni-AI/drama3-mlx API](https://github.com/Sogni-AI/drama3-mlx/blob/ecc235039ccd5f982f66a6982184f57432354ab4/drama3_mlx/model.py#L79) -is the behavioral reference for exposing these controls. These patches are -independent native Zig implementations; no Python inference dependency is added. +is the behavioral reference for exposing these controls. The full-model engine serves them from +upstream TensorFold v1.1.9 (#1277) and the NVFP4 profiles from their patches; all are +independent native Zig implementations, and no Python inference dependency is added. CPU regressions exercise validation and the values actually submitted through the HTTP route. Device/model determinism and audio-quality qualification are diff --git a/recipe/pilot.py b/recipe/pilot.py index ceb0a04..cc1c95b 100644 --- a/recipe/pilot.py +++ b/recipe/pilot.py @@ -74,7 +74,8 @@ def main() -> int: '--context', '8192', '--parallel', '1', '--no-drafts', '--prompt-cache-gib', '0', '--keep-warm', '0', *serve_args] backend_environment = ['TENSORFOLD_MEMORY_RESERVE_GIB=34', - 'TENSORFOLD_CUDA_MEMORY_LIMIT_GB=54'] + 'TENSORFOLD_CUDA_MEMORY_LIMIT_GB=54', + *weight_selection.native_environment(args.weights)] allocator_caps = {'tracked_cuda_and_pinned_host_buffers': 54} elif args.serve_args: parser.error('Extra serve flags apply to the native backend only') diff --git a/recipe/test_build_engine.py b/recipe/test_build_engine.py index fcdebd2..38c587a 100644 --- a/recipe/test_build_engine.py +++ b/recipe/test_build_engine.py @@ -119,6 +119,21 @@ def test_missing_cuda_series_is_refused(self): self.assertIn('has no *.patch files', result.stderr) self.assertFalse(target.exists()) + def test_empty_manifest_builds_the_unpatched_pin_and_a_missing_manifest_is_refused(self): + directory = self.repo / self.series['full'] / 'patches' + (directory / '0001-fixture.patch').unlink() + (directory / 'SHA256SUMS').write_text('') + result, target = self.build('Linux', 'full') + self.assertEqual(result.returncode, 0, result.stderr) + head = subprocess.check_output(['git', '-C', str(target), 'rev-parse', 'HEAD'], text=True).strip() + self.assertEqual(head, self.base) + self.assertIn(f'{self.base} + 0 patches', result.stdout) + (directory / 'SHA256SUMS').unlink() + result, target = self.build('Linux', 'full', 'build-no-manifest') + self.assertNotEqual(result.returncode, 0) + self.assertIn('has no SHA256SUMS manifest', result.stderr) + self.assertFalse(target.exists()) + def test_manifest_covers_exactly_the_selected_patches(self): directory = self.repo / self.series['cuda'] / 'patches' manifest = directory / 'SHA256SUMS' diff --git a/recipe/test_weight_selection.py b/recipe/test_weight_selection.py index d5220da..ea08482 100644 --- a/recipe/test_weight_selection.py +++ b/recipe/test_weight_selection.py @@ -59,6 +59,12 @@ def test_guidance_override_replaces_all_defaults(self): self.assertEqual(selection.serve_arguments(mode, arguments), arguments) self.assertEqual(arguments, ['--max-frames', '8']) + def test_native_environment_preserves_full_model_prefill(self): + for mode in ('full', None): + with self.subTest(mode=mode): + self.assertEqual(selection.native_environment(mode), ['TF_DRAMA_PREFILL_ROWS=128']) + self.assertEqual(selection.native_environment('nvfp4'), []) + def test_explicit_paths_override_defaults_but_not_manifest(self): override = self.root / 'custom-checkpoint' selection.checkpoint_path('nvfp4', None).rename(override) diff --git a/recipe/weight_selection.py b/recipe/weight_selection.py index 4628fa3..93d58a1 100644 --- a/recipe/weight_selection.py +++ b/recipe/weight_selection.py @@ -42,6 +42,11 @@ def serve_arguments(choice: str | None, arguments: list[str]) -> list[str]: return selected +def native_environment(choice: str | None) -> list[str]: + # Preserve batched prefill when the pilot clears the inherited environment. + return [] if choice == 'nvfp4' else ['TF_DRAMA_PREFILL_ROWS=128'] + + def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open('rb') as file: diff --git a/scripts/build-engine.sh b/scripts/build-engine.sh index 838d667..eeeb941 100755 --- a/scripts/build-engine.sh +++ b/scripts/build-engine.sh @@ -66,9 +66,10 @@ esac shopt -s nullglob patches=() if [[ -n $patch_dir ]]; then - patches=("$patch_dir"/*.patch) - ((${#patches[@]})) || die "$patch_dir has no *.patch files" [[ -f $patch_dir/SHA256SUMS ]] || die "$patch_dir has no SHA256SUMS manifest" + patches=("$patch_dir"/*.patch) + # An empty manifest declares an empty series: the pin already carries every feature. + ((${#patches[@]})) || [[ ! -s $patch_dir/SHA256SUMS ]] || die "$patch_dir has no *.patch files" manifest_pattern='^[[:xdigit:]]{64} ([^/[:space:]]+\.patch)$' listed_patches=() while IFS= read -r manifest_line || [[ -n $manifest_line ]]; do @@ -83,9 +84,9 @@ if [[ -n $patch_dir ]]; then done ((matches == 1)) || die "patch manifest must list every selected patch exactly once" done - if command -v sha256sum >/dev/null; then + if ((${#patches[@]})) && command -v sha256sum >/dev/null; then (cd "$patch_dir" && sha256sum -c SHA256SUMS) || die "patch checksum verification failed" - else + elif ((${#patches[@]})); then (cd "$patch_dir" && shasum -a 256 -c SHA256SUMS) || die "patch checksum verification failed" fi fi