From 304e1c3faa33aadffc1e2d27f8151a3c41916c40 Mon Sep 17 00:00:00 2001
From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
Date: Sun, 25 Oct 2026 11:45:30 +0800
Subject: [PATCH 1/7] build: apply checksummed CUDA NVFP4 recipe patches
---
engine/nvfp4/UPSTREAM | 4 +-
recipe/test_build_engine.py | 142 ++++++++++++++++++++++++++++++++++++
scripts/build-engine.sh | 3 +-
3 files changed, 146 insertions(+), 3 deletions(-)
create mode 100644 recipe/test_build_engine.py
diff --git a/engine/nvfp4/UPSTREAM b/engine/nvfp4/UPSTREAM
index 26219bf..6e1c2f7 100644
--- a/engine/nvfp4/UPSTREAM
+++ b/engine/nvfp4/UPSTREAM
@@ -1,5 +1,5 @@
-# The engine `scripts/build-engine.sh --weights nvfp4` builds, unpatched: the only engine that loads the NVFP4
-# pruned weights (plotarmordev/drama-3-NVFP4-Pruned). Commit b1f4b9b4 on branch drama-nvfp4 of the TensorFold fork;
+# The CUDA engine base for `scripts/build-engine.sh --weights nvfp4`, followed by patches/.
+# Commit b1f4b9b4 on branch drama-nvfp4 of the TensorFold fork loads the NVFP4 pruned weights;
# results/full-vs-quant-01 measured the same source (v1.0.11-193-g70dec469; the two differ in comments and test
# wording only).
TENSORFOLD_REPOSITORY=https://github.com/plotarmordev/TensorFold.git
diff --git a/recipe/test_build_engine.py b/recipe/test_build_engine.py
new file mode 100644
index 0000000..8540af0
--- /dev/null
+++ b/recipe/test_build_engine.py
@@ -0,0 +1,142 @@
+"""Exercise platform selection and real git-am/checksum handling without a compiler or network."""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path
+import shlex
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class BuildEngineTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.root = Path(self.temp.name)
+ self.repo = self.root / 'recipe'
+ self.source = self.root / 'source'
+ self.bin = self.root / 'bin'
+ self.bin.mkdir()
+ self.source.mkdir()
+ self.env = dict(os.environ, PATH=str(self.bin) + os.pathsep + os.environ['PATH'])
+ self.env.update(GIT_AUTHOR_NAME='Fixture Contributor', GIT_COMMITTER_NAME='Fixture Contributor',
+ GIT_AUTHOR_EMAIL='fixture@example.invalid', GIT_COMMITTER_EMAIL='fixture@example.invalid',
+ GIT_AUTHOR_DATE='2026-01-01T00:00:00Z', GIT_COMMITTER_DATE='2026-01-01T00:00:00Z')
+ self.git('init', '-q')
+ self.git('config', 'commit.gpgsign', 'false')
+ (self.source / '.zig-version').write_text('0.17.0\n')
+ self.git('add', '.zig-version')
+ self.git('commit', '-qm', 'Fixture engine base')
+ self.base = self.git('rev-parse', 'HEAD').stdout.strip()
+ script = self.repo / 'scripts' / 'build-engine.sh'
+ script.parent.mkdir(parents=True)
+ shutil.copyfile(ROOT / 'scripts' / 'build-engine.sh', script)
+ self.series = {'full': 'engine', 'cuda': 'engine/nvfp4', 'metal': 'engine/nvfp4/macos'}
+ for mode, relative in self.series.items():
+ target = self.repo / relative
+ patches = target / 'patches'
+ patches.mkdir(parents=True)
+ (target / 'UPSTREAM').write_text(
+ f'TENSORFOLD_REPOSITORY={shlex.quote(str(self.source))}\n'
+ f'TENSORFOLD_COMMIT={self.base}\n')
+ filename = f'applied-{mode}'
+ (self.source / filename).write_text(mode + '\n')
+ self.git('add', filename)
+ self.git('commit', '-qm', f'Fixture {mode} patch')
+ content = self.git('format-patch', '-1', '--stdout').stdout.encode()
+ (patches / '0001-fixture.patch').write_bytes(content)
+ digest = hashlib.sha256(content).hexdigest()
+ (patches / 'SHA256SUMS').write_text(f'{digest} 0001-fixture.patch\n')
+ self.executable('uname', '#!/bin/sh\nprintf "%s\\n" "$TEST_PLATFORM"\n')
+ self.executable('nvcc', '#!/bin/sh\nexit 0\n')
+ self.executable('zig', '''#!/usr/bin/env python3
+import json
+from pathlib import Path
+import sys
+if sys.argv[1:] == ['version']:
+ print('0.17.0')
+else:
+ Path('build-args.json').write_text(json.dumps(sys.argv[1:]))
+ for target in ('native', 'drama3-generate', 'drama3-voice'):
+ if target in sys.argv[1:]:
+ name = 'native/bin/tensorfold-native' if target == 'native' else 'bin/' + target
+ path = Path('zig-out') / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(target)
+''')
+
+ def git(self, *arguments):
+ return subprocess.run(['git', *arguments], cwd=self.source, env=self.env,
+ capture_output=True, text=True, check=True, timeout=20)
+
+ def executable(self, name, content):
+ path = self.bin / name
+ path.write_text(content)
+ path.chmod(0o755)
+
+ def build(self, platform, weights, name='build'):
+ target = self.root / name
+ result = subprocess.run(
+ ['bash', str(self.repo / 'scripts/build-engine.sh'), '--weights', weights, str(target)],
+ env=dict(self.env, TEST_PLATFORM=platform, ZIG=str(self.bin / 'zig'), NVCC=str(self.bin / 'nvcc')),
+ capture_output=True, text=True, timeout=30)
+ return result, target
+
+ def test_each_platform_applies_only_its_selected_patch_series(self):
+ for platform, weights, selected in [('Linux', 'nvfp4', 'cuda'), ('Darwin', 'nvfp4', 'metal'),
+ ('Linux', 'full', 'full'), ('Darwin', 'full', 'full')]:
+ with self.subTest(platform=platform, weights=weights):
+ result, target = self.build(platform, weights, platform + weights)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ for mode in self.series:
+ self.assertEqual((target / f'applied-{mode}').exists(), mode == selected)
+ arguments = json.loads((target / 'build-args.json').read_text())
+ expected = ['native'] if selected == 'cuda' else ['native', 'drama3-generate']
+ if selected == 'metal':
+ expected.append('drama3-voice')
+ self.assertEqual([a for a in arguments if a in ('native', 'drama3-generate', 'drama3-voice')], expected)
+ self.assertEqual(any(a.startswith('-Dnvcc=') for a in arguments), platform == 'Linux')
+
+ def test_corrupt_cuda_patch_is_refused_before_checkout_or_build(self):
+ path = self.repo / self.series['cuda'] / 'patches/0001-fixture.patch'
+ path.write_bytes(path.read_bytes() + b'changed\n')
+ result, target = self.build('Linux', 'nvfp4')
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn('patch checksum verification failed', result.stderr)
+ self.assertFalse(target.exists())
+
+ def test_missing_cuda_series_is_refused(self):
+ (self.repo / self.series['cuda'] / 'patches/0001-fixture.patch').unlink()
+ result, target = self.build('Linux', 'nvfp4')
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn('has no *.patch files', result.stderr)
+ self.assertFalse(target.exists())
+
+ def test_rebuild_reapplies_the_same_cuda_patch(self):
+ first, target = self.build('Linux', 'nvfp4')
+ self.assertEqual(first.returncode, 0, first.stderr)
+ head = subprocess.check_output(['git', '-C', str(target), 'rev-parse', 'HEAD'], text=True).strip()
+ second, _ = self.build('Linux', 'nvfp4')
+ self.assertEqual(second.returncode, 0, second.stderr)
+ self.assertEqual(subprocess.check_output(['git', '-C', str(target), 'rev-parse', 'HEAD'], text=True).strip(), head)
+ self.assertEqual((target / 'applied-cuda').read_text(), 'cuda\n')
+
+ def test_modified_checkout_is_preserved_and_refused(self):
+ first, target = self.build('Linux', 'nvfp4')
+ self.assertEqual(first.returncode, 0, first.stderr)
+ (target / '.zig-version').write_text('operator edit\n')
+ second, _ = self.build('Linux', 'nvfp4')
+ self.assertNotEqual(second.returncode, 0)
+ self.assertIn('local changes', second.stderr)
+ self.assertEqual((target / '.zig-version').read_text(), 'operator edit\n')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/scripts/build-engine.sh b/scripts/build-engine.sh
index 2565afa..714bd64 100755
--- a/scripts/build-engine.sh
+++ b/scripts/build-engine.sh
@@ -5,7 +5,7 @@
#
# --weights full (the default) upstream TensorFold at engine/UPSTREAM with the opt-in patches in
# engine/patches: the native server and drama3-generate, in build/tensorfold
-# --weights nvfp4 CUDA: engine/nvfp4/UPSTREAM, unpatched native server.
+# --weights nvfp4 CUDA: engine/nvfp4/UPSTREAM plus its patches, native server.
# Metal: engine/nvfp4/macos/UPSTREAM plus its patches, native server and voice tools.
# Both use plotarmordev/drama-3-NVFP4-Pruned, in build/tensorfold-nvfp4.
#
@@ -48,6 +48,7 @@ case $weights in
;;
nvfp4)
pin=$repo/engine/nvfp4/UPSTREAM build=${1:-$repo/build/tensorfold-nvfp4}
+ patch_dir=$repo/engine/nvfp4/patches
targets=(native)
if [[ $platform == Darwin ]]; then
pin=$repo/engine/nvfp4/macos/UPSTREAM
From a1a1651f8de2ca92ffde2c2a353b231365e4c7b3 Mon Sep 17 00:00:00 2001
From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
Date: Sun, 25 Oct 2026 11:59:48 +0800
Subject: [PATCH 2/7] build: reject incomplete or duplicate patch checksum
manifests
---
recipe/test_build_engine.py | 18 ++++++++++++++++++
scripts/build-engine.sh | 15 +++++++++++++++
2 files changed, 33 insertions(+)
diff --git a/recipe/test_build_engine.py b/recipe/test_build_engine.py
index 8540af0..fcdebd2 100644
--- a/recipe/test_build_engine.py
+++ b/recipe/test_build_engine.py
@@ -119,6 +119,24 @@ def test_missing_cuda_series_is_refused(self):
self.assertIn('has no *.patch files', 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'
+ original = manifest.read_text()
+ unlisted = directory / '0002-unlisted.patch'
+ unlisted.write_bytes((self.repo / self.series['metal'] / 'patches/0001-fixture.patch').read_bytes())
+ result, target = self.build('Linux', 'nvfp4')
+ self.assertNotEqual(result.returncode, 0, 'an unlisted patch must never be applied')
+ self.assertFalse(target.exists())
+ unlisted.unlink()
+ for content in (original + original, original.replace('0001-fixture.patch', 'missing.patch'),
+ original + hashlib.sha256(b'notes').hexdigest() + ' README.md\n'):
+ with self.subTest(manifest=content):
+ manifest.write_text(content)
+ result, target = self.build('Linux', 'nvfp4')
+ self.assertNotEqual(result.returncode, 0)
+ self.assertFalse(target.exists())
+
def test_rebuild_reapplies_the_same_cuda_patch(self):
first, target = self.build('Linux', 'nvfp4')
self.assertEqual(first.returncode, 0, first.stderr)
diff --git a/scripts/build-engine.sh b/scripts/build-engine.sh
index 714bd64..838d667 100755
--- a/scripts/build-engine.sh
+++ b/scripts/build-engine.sh
@@ -68,6 +68,21 @@ 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"
+ manifest_pattern='^[[:xdigit:]]{64} ([^/[:space:]]+\.patch)$'
+ listed_patches=()
+ while IFS= read -r manifest_line || [[ -n $manifest_line ]]; do
+ [[ $manifest_line =~ $manifest_pattern ]] || die "invalid patch manifest entry"
+ listed_patches+=("${BASH_REMATCH[1]}")
+ done < "$patch_dir/SHA256SUMS"
+ ((${#listed_patches[@]} == ${#patches[@]})) || die "patch manifest must list every selected patch exactly once"
+ for patch in "${patches[@]}"; do
+ matches=0
+ for listed_patch in "${listed_patches[@]}"; do
+ if [[ $listed_patch == "${patch##*/}" ]]; then matches=$((matches + 1)); fi
+ done
+ ((matches == 1)) || die "patch manifest must list every selected patch exactly once"
+ done
if command -v sha256sum >/dev/null; then
(cd "$patch_dir" && sha256sum -c SHA256SUMS) || die "patch checksum verification failed"
else
From 37a6832b94d058e67345db9dd6b0f8f07a37e4d8 Mon Sep 17 00:00:00 2001
From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
Date: Sun, 25 Oct 2026 12:05:00 +0800
Subject: [PATCH 3/7] Add protected long-form speech chunks as CUDA NVFP4 patch
0001
---
CHANGELOG.md | 7 +
README.md | 22 +-
.../0001-protected-speech-chunks.patch | 733 ++++++++++++++++++
engine/nvfp4/patches/README.md | 27 +
engine/nvfp4/patches/SHA256SUMS | 1 +
engine/patches/README.md | 3 +
recipe/README.md | 3 +
7 files changed, 794 insertions(+), 2 deletions(-)
create mode 100644 engine/nvfp4/patches/0001-protected-speech-chunks.patch
create mode 100644 engine/nvfp4/patches/README.md
create mode 100644 engine/nvfp4/patches/SHA256SUMS
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8a7bd6d..bfa6c8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,9 @@
### Fixed
+- CUDA NVFP4 long-form splitting preserves paired delivery tags, bracketed cues and special tokens,
+ including topic-shift handling, and recognizes CJK sentence/clause punctuation. An indivisible
+ protected span may exceed the character target; existing prompt and frame limits still apply.
- Metal reference-prefill reuse now invalidates before incompatible/default prefill, compares the
complete invariant prefix plus tower lookahead, preserves original chunk boundaries, and reuses
one GPU-owned snapshot buffer pair per lane instead of allocating on every voice switch.
@@ -19,6 +22,10 @@
### Added
+- A checksummed CUDA NVFP4 engine patch series without changing its pinned upstream or weights.
+ Model-free build tests exercise real patch application, selection on Linux/macOS, corruption
+ refusal, repeat builds and preservation of modified checkouts. Manifests must cover each
+ selected patch exactly once; extra, missing and duplicate entries fail before checkout.
- Native Apple-silicon support for the pinned NVFP4-pruned checkpoint: packed experts, compact
original-ID routing and reference-conditioned semantic CFG, with independent backbone states.
The Mac engine uses `engine/nvfp4/macos/UPSTREAM` and its checksum-verified patches; the existing
diff --git a/README.md b/README.md
index 589d3f1..35e647b 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@
[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
-measured CUDA engine pin and uses a separate upstream pin plus Metal patches on Mac. It builds the engine
+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.
**Built with Fish Audio.** The model weights are not in this repository; the launcher downloads them from
@@ -64,7 +64,8 @@ For the full model, write `--weights full` in both commands.
The first start downloads the weights at the revision pinned in [`recipe/sources.json`](recipe/sources.json),
into `drama-3-NVFP4-Pruned/` or `drama-3/`. Every start checks them against [`recipe/checksums`](recipe/checksums)
and refuses missing or changed files; for the 21 GiB NVFP4 weights that takes about 11 seconds on a GB10.
-On Linux, `--weights nvfp4` builds [`engine/nvfp4/UPSTREAM`](engine/nvfp4/UPSTREAM) and serves with
+On Linux, `--weights nvfp4` builds [`engine/nvfp4/UPSTREAM`](engine/nvfp4/UPSTREAM) plus
+[`engine/nvfp4/patches`](engine/nvfp4/patches) and serves with
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.
@@ -139,6 +140,23 @@ Guidance-enabled replies carry the configured γ in `X-Cfg-Gamma`; builtin voice
## The patches
+### CUDA NVFP4 long speech
+
+The CUDA NVFP4 patch series preserves delivery markup when `--long-form` splits a request.
+Paired tags such as `…`, bracketed cues and special tokens stay whole;
+Chinese/Japanese sentence and clause punctuation are recognized. A topic-shift mark inside a
+protected span does not create a cut. Plain English without these spans retains its existing cuts.
+
+`--long-form-chars` remains a code-point target, with one exception: an indivisible protected span
+may exceed it. The engine's prompt, segment-count and generated-frame limits still apply.
+The full-model and Metal NVFP4 engines do not expose this legacy long-form route.
+
+The behavioral reference is [Sogni-AI/drama3-mlx's chunking](https://github.com/Sogni-AI/drama3-mlx/blob/ecc235039ccd5f982f66a6982184f57432354ab4/drama3_mlx/chunking.py).
+The native patch extends TensorFold's own implementation; it does not copy that Fish-licensed source.
+See the [CUDA patch index](engine/nvfp4/patches/README.md) for scope and tests.
+
+### Full-model engine
+
The 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: batched prefill is on by default (`TF_DRAMA_PREFILL_ROWS=1`
diff --git a/engine/nvfp4/patches/0001-protected-speech-chunks.patch b/engine/nvfp4/patches/0001-protected-speech-chunks.patch
new file mode 100644
index 0000000..dfccd79
--- /dev/null
+++ b/engine/nvfp4/patches/0001-protected-speech-chunks.patch
@@ -0,0 +1,733 @@
+From 3babff305c51109c99d250033e3ce632edce7127 Mon Sep 17 00:00:00 2001
+From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
+Date: Sun, 25 Oct 2026 11:58:30 +0800
+Subject: [PATCH] server: preserve controls in long-form speech chunks
+
+Keep complete special tokens, bracketed cues and nested paired tags intact across sentence, clause, space and code-point splitting. Protect topic-shift preprocessing and recognize CJK boundaries without changing prompt or frame ceilings.
+
+Exercise exact whole and streamed prompt segments, oversized spans, malformed controls and redraws. Drain inherited socket fixtures before joining so CPU validation completes on macOS.
+
+Refs: https://github.com/plotarmordev/drama-tensorfold/issues/25
+---
+ CHANGELOG.md | 1 +
+ docs/drama-voices.md | 14 +--
+ zig/src/server/cli_speech.zig | 2 +-
+ zig/src/server/drama_joins.zig | 11 +-
+ zig/src/server/drama_long.zig | 122 ++++++++++++++++++----
+ zig/src/server/drama_shift.zig | 20 +++-
+ zig/src/server/drama_spans.zig | 128 ++++++++++++++++++++++++
+ zig/src/server/speech_long.zig | 2 +-
+ zig/src/server/speech_long_test.zig | 55 +++++++++-
+ zig/src/server/speech_mid_test.zig | 2 +-
+ zig/src/server/speech_redraw_test.zig | 10 +-
+ zig/src/server/speech_register_test.zig | 3 +-
+ zig/src/server/speech_stream_test.zig | 4 +-
+ 13 files changed, 332 insertions(+), 42 deletions(-)
+ create mode 100644 zig/src/server/drama_spans.zig
+
+diff --git a/CHANGELOG.md b/CHANGELOG.md
+index 2af98ce5..5dd216c0 100644
+--- a/CHANGELOG.md
++++ b/CHANGELOG.md
+@@ -4,6 +4,7 @@ Each release's page on GitHub has its notes and measurements. See [the 1.0.0 rel
+
+ ## Unreleased
+
++- speech: long-form Drama 3 splits preserve complete special tokens, bracketed cues and nested paired direction tags, even when one span exceeds the character target. CJK punctuation supplies sentence and clause boundaries without requiring spaces, and topic-shift markers inside protected spans cannot split them. Existing input, prompt-token, segment and frame limits still apply ([docs](docs/drama-voices.md#long-form-replies)).
+ - speech: Drama 3's DAC activation planes are bounded by the decode chunk and causal halo rather than the utterance limit. QKV history retains absolute frame indexing and attention tile boundaries. `zig build drama-codec-chunk` builds `oracle/bin/tf-drama-codec-chunk CHECKPOINT`, a GPU byte-equality gate against the original full-plane layout; the trace diagnostic explicitly retains that reference layout.
+ - speech: `--silence-mid-s S` (CUDA; 0 for off, or 3 to below 10; default 0) ends a Drama 3 synthesis as `silence` when its end of speech comes inside S seconds or more below -40 dBFS after 1 s or more voiced: a reply that fell silent mid-way and stopped there, its last words never spoken, as one 4-bit take did after a 9.0 s hole. Holes that recover are left alone, since a client that shortens pauses never plays them, and a reply the frame cap stops is not judged. Such a draw is redrawn under the silence redraws' rules, a long-form segment in place, and whole replies count the draws it ended in `X-Silence-Mid-Trips`. Replayed at S = 3 over 2272 draws in the engine's frame accounting, it ended 15 of the 2047 that the 10 s rule left alone; all 8 with a text to judge by had lost words, and none of them had spoken in full. With S = 0 replies are as before ([docs](docs/drama-voices.md#holes-that-end-a-reply))
+ - speech: `--register-redraw-st R` (0 to 24, default 0: off) draws a whole Drama 3 reply once more when a phrase after the first opens R semitones or more above the median of its other phrase openings with `--register-redraw-high F` percent (default 20) of its voiced frames over `--register-high-hz H` (default 210), the thin, high phrase that sounds stitched into a reply. The redraw starts only if t + d fits the silence redraws' budget and `X-Deadline-S`, and the draw with the smaller jump is sent, never one that ended in silence or short; a tie keeps the first. Replies carry `X-Register-Trip` and `X-Register-Redraws`; streams and multi-segment long-form replies are not measured, and a draw with a NaN or infinite sample fails the reply before any measure. The measure ports the numpy one it was calibrated with: on 77 takes it decided every trip the same way at R = 4, F = 20, H = 210 (evidence on that corpus, not exact parity at every threshold; those settings tripped 6 of 51 replies of a day's use), at about 1.2 ms per second of audio. With R = 0 replies are as before ([docs](docs/drama-voices.md#register-redraws))
+diff --git a/docs/drama-voices.md b/docs/drama-voices.md
+index e27e7e70..2ee8d2a9 100644
+--- a/docs/drama-voices.md
++++ b/docs/drama-voices.md
+@@ -304,19 +304,21 @@ tensorfold serve DRAMA3_DIR --voices voices/ --default-voice NAME --long-form
+
+ With `--long-form`, a speech input longer than `--long-form-chars` (default 500 code points) is spoken in segments instead of in one synthesis. Each segment hears the segments before it: the earlier segments' text and generated codes are added as references after the request's references and the voice's, with the latest last. This follows the way Fish's long-form inference keeps earlier segments in context.
+
+-- **Segments.** The input is cut into the fewest segments that fit the limit, with cuts only at sentence ends (`.` `!` `?` `…` followed by white space, keeping closing quotes and brackets, and line breaks).
++- **Segments.** The input is cut into the fewest segments that fit the character target, with cuts at sentence ends (`.` `!` `?` `…` followed by white space; `。` `!` `?` `;` without needing white space), keeping closing quotes and brackets, and at line breaks.
+ - Among the cuts that give that many segments, the server picks the most balanced lengths, and prefers a cut where the text turns.
+ - The text turns at a blank line, after a question, or where the next sentence opens a new move: it starts with So, Okay, OK, Now, Right, Anyway, Also, Next, Finally, But or Here's, or with a number and "thing"/"things" (One thing, Two things, 3 things).
+ - A turn wins over a cut that is up to about 15% of a segment better balanced.
+- - Only a sentence longer than the limit is cut inside, at `;` `:` `,`, then at spaces, then anywhere.
++ - Only a sentence longer than the target is cut inside, at `;` `:` `,` before white space or `,` `:` `、`, then at spaces, then between code points.
++ - A complete `<|...|>` token, balanced `[...]` cue or correctly nested `...` span stays whole. Simple tag names start with an ASCII letter and contain letters, digits, `_` or `-`; names match exactly, and attributes are not parsed. Nested spans stay with their outer span; adjacent controls remain separate. An unclosed or crossed delimiter is ordinary text, while complete spans inside or after it still stay whole.
++ - An indivisible span may exceed the character target; other text retains the target. Input, prompt-token, frame and segment-count limits still apply. The splitter preserves the original text and never inserts or reopens tags.
+ - **History.** `--long-form-history K` (0 to 3, default 1) sets how many earlier segments a segment hears. The oldest is dropped when the prompt would exceed the engine's prompt limit.
+ - **Joins.**
+ - **Pause.** At each join, the silence already there is measured: the end of one segment plus the start of the next, in 10 ms frames below -42 dBFS. Only the shortfall to the boundary's target is added, never cutting existing silence. Targets:
+- - `.` or `!`: 0.42 s
++ - `.` `!` `。` `!` or `;`: 0.42 s
+ - `…` or `...`: 0.55 s
+ - a turn (a question, or a sentence opening a new move): 0.60 s
+ - a blank line: 0.70 s
+- - a cut inside a sentence (at `,` `;` `:` or a space): 0.20 s
++ - a cut inside a sentence (at `,` `;` `:` `,` `:` `、` or a space): 0.20 s
+ - **Room tone.** The added pause is copied from the earlier segment's quietest 100 ms in its last second, played back and forth. It is digital silence when that segment has no quiet tail.
+ - **Level.** A segment whose speech level (the median dBFS of its frames at or above -42 dBFS) is more than 1.5 dB from the median of the earlier segments gets one constant gain. The gain brings it to 1.5 dB, is at most 3 dB either way, and never raises a peak past 0.99.
+ - **Seeds.** Each segment's seed comes from its own prompt rows, as a single synthesis's does.
+@@ -337,9 +339,9 @@ tensorfold serve DRAMA3_DIR --voices voices/ --default-voice NAME --long-form --
+
+ A client marks a topic shift with `¶` (U+00B6), sent as ` ¶ `. A caller's wrapper writes one for each blank line it collapses. With `--shift-pause-s T` (0 to 3 seconds, default 0: off; needs `--long-form`), the reply pauses for at least T seconds at each mark, and says where those pauses are.
+
+-- **Marks.** A mark counts only after `.` `!` `?` or `…` (closing quotes and brackets may sit between), and only with words on both sides of it. Here a word means a Unicode letter or number (general category L or N, so `次へ`, `Été` and `2` count, while `!!!`, `×` and `€` do not), outside `[…]` and `<…>` controls, between the mark and the last cut and between it and the next mark. A cue may open the text after a mark (`done. ¶ [curious] Should I…`), but a mark followed by punctuation, symbols or cues alone does not count. Several marks in a row count as one. Every other mark is dropped, and the server logs how many. Every mark, with the white space after it, is removed before anything else reads the input. The tokenizer, the long-form history, a plain redraw's text and the short-reply rule's character count never see one.
++- **Marks.** A mark counts only after `.` `!` `?` `…` `。` `!` `?` or `;` (closing quotes and brackets may sit between), and only with words on both sides of it. Here a word means a Unicode letter or number (general category L or N, so `次へ`, `Été` and `2` count, while `!!!`, `×` and `€` do not), outside `[…]` and `<…>` controls, between the mark and the last cut and between it and the next mark. A cue may open the text after a mark (`done. ¶ [curious] Should I…`), but a mark followed by punctuation, symbols or cues alone does not count. Marks inside an indivisible control span do not force a cut. Several marks in a row count as one. Every other mark is dropped, and the server logs how many. Every mark, with the white space after it, is removed before anything else reads the input. The tokenizer, the long-form history, a plain redraw's text and the short-reply rule's character count never see one.
+ - **Cues that trail a sentence.** Put the mark after the sentence's end and before a cue that trails it: `nights in a row now. ¶ [small laugh] The calendar`. The cue then opens the next segment. Written after the cue (`now. [small laugh] ¶`), the mark follows `]`, not a sentence end, so it is dropped.
+-- **Cuts.** Each mark that counts cuts a long-form segment there, even in an input short enough for one segment. The text between marks is split as long-form splits any input, and the segment cap still applies.
++- **Cuts.** Each mark that counts cuts a long-form segment there, even in an input short enough for one segment. The text between marks is split as long-form splits any input, including the indivisible-span exception to the character target.
+ - **Pauses.** At a mark's join, the silent 10 ms frames around the join (under -42 dBFS, counted on the reply's own grid from its first sample, as a client's pacing counts them) are made to last at least T seconds. Quiet already there counts, and is never cut. The fill is added, then measured in the audio as sent. If the crossfades or the grid leave it short, more room tone goes in its middle until it is long enough. The fill is room tone: the quietest silent 100 ms (but not digital silence, under -90 dBFS) of the earlier segment's last second. Failing that it comes from all audio earlier in the reply, then from the next segment. If none of these has one, it is noise at -65 dBFS, never zeros. Its samples are kept 6 dB under the floor, so every 10 ms of it is silent. The fill fades in from the earlier segment's last samples and out into the next segment's first samples, each over 15 ms at equal power. Both segments are mirrored at their seams, so neither segment's own audio changes.
+ - **`X-Pause-Spans: s1-e1,s2-e2,…`** lists one span per mark the reply reached, as sample offsets into the audio exactly as sent (start inclusive, end exclusive, ascending). A span runs from the start of the earlier segment's trailing quiet to the end of the next segment's leading quiet, fill included, and always contains the silent frames measured around the join, so it holds at least T seconds of them. With `stream: true` the spans come as a trailer, declared next to `X-Long-Form-Segments`. The header is absent when there are no spans.
+ - **Redraws.** Redraws work per segment as usual. The spans are measured on each segment's final take, so they always describe the audio delivered. Segments under 200 characters fall outside the short-reply rule, and only the silence guard covers them.
+diff --git a/zig/src/server/cli_speech.zig b/zig/src/server/cli_speech.zig
+index a60cac35..113c6f1e 100644
+--- a/zig/src/server/cli_speech.zig
++++ b/zig/src/server/cli_speech.zig
+@@ -47,7 +47,7 @@ pub fn flag(a: Allocator, out: *cli.Args, name: []const u8, v: []const u8, u: *c
+ const is = std.mem.eql;
+ if (is(u8, name, "--voices")) out.voices = v else if (is(u8, name, "--default-voice")) out.default_voice = v else if (is(u8, name, "--long-form")) out.long_form = true else if (is(u8, name, "--long-form-chars")) {
+ const n = try cli.int(u, a, name, v);
+- if (n < 1 or n > 4096) return cli.fail(u, a, "argument --long-form-chars: characters a segment holds, from 1 to 4096: '{s}'", .{v});
++ if (n < 1 or n > 4096) return cli.fail(u, a, "argument --long-form-chars: target characters a segment holds, from 1 to 4096: '{s}'", .{v});
+ out.long_form_chars = @intCast(n);
+ out.long_form_set = true;
+ } else if (is(u8, name, "--long-form-history")) {
+diff --git a/zig/src/server/drama_joins.zig b/zig/src/server/drama_joins.zig
+index 912eaf2a..57ee88ff 100644
+--- a/zig/src/server/drama_joins.zig
++++ b/zig/src/server/drama_joins.zig
+@@ -39,16 +39,23 @@ pub fn boundary(left: []const u8, gap: []const u8, right: []const u8) Boundary {
+ const at = lastStart(left[0..end]);
+ const cp = std.unicode.utf8Decode(left[at..end]) catch break;
+ switch (cp) {
+- '"', '\'', ')', ']', 0x201d, 0x2019, 0xbb => end = at,
++ '"', '\'', ')', ']', 0x201d, 0x2019, 0xbb, 0x300d, 0x300f, 0x3011, 0xff09 => end = at,
+ 0x2026 => return .ellipsis,
+ '.' => return if (std.mem.endsWith(u8, left[0..end], "...")) .ellipsis else .sentence,
+- '!' => return .sentence,
++ '!', 0x3002, 0xff01, 0xff1b => return .sentence,
+ else => break,
+ }
+ }
+ return if (std.mem.indexOfAny(u8, gap, "\n\r") != null) .sentence else .clause;
+ }
+
++test "CJK punctuation selects sentence, question and clause joins" {
++ for ([_][]const u8{ "\u{4f60}\u{597d}\u{3002}", "\u{4f60}\u{597d}\u{ff01}\u{300d}", "\u{4f60}\u{597d}\u{ff1b}" }) |left|
++ try std.testing.expectEqual(Boundary.sentence, boundary(left, "", "\u{4e16}\u{754c}"));
++ try std.testing.expectEqual(Boundary.topic, boundary("\u{4f60}\u{597d}\u{ff1f}\u{300d}", "", "\u{4e16}\u{754c}"));
++ try std.testing.expectEqual(Boundary.clause, boundary("\u{4f60}\u{597d}\u{ff0c}", "", "\u{4e16}\u{754c}"));
++}
++
+ /// The start of the last code point of valid UTF-8 `text`.
+ fn lastStart(text: []const u8) usize {
+ var at = text.len - 1;
+diff --git a/zig/src/server/drama_long.zig b/zig/src/server/drama_long.zig
+index 77d0aff1..e87207bc 100644
+--- a/zig/src/server/drama_long.zig
++++ b/zig/src/server/drama_long.zig
+@@ -1,10 +1,11 @@
+ //! Drama 3 long-form speech: an input cut at sentence ends into segments, each spoken after hearing those before it.
+ const std = @import("std");
+ const drama_prompt = @import("drama_prompt.zig");
++const drama_spans = @import("drama_spans.zig");
+ const Reference = drama_prompt.Reference;
+ const Allocator = std.mem.Allocator;
+
+-/// --long-form's segments of at most `chars`, each hearing `history` before it; --shift-pause-s: seconds at each `¶`.
++/// --long-form's character target; indivisible controls may exceed it. `history` sets earlier segments heard.
+ pub const Options = struct { chars: u32 = 500, history: u8 = 1, shift_pause_s: f32 = 0 };
+
+ pub const max_history = 3;
+@@ -14,13 +15,17 @@ pub const max_frames = 6000;
+
+ /// Where text may be cut, coarsest first.
+ const Level = enum { sentence, clause, space, any };
++const Protection = struct { base: [*]const u8, spans: []const drama_spans.Span };
+
+-/// The input's segments: the fewest that fit `chars`, balanced, ending where the text turns (see `balance`).
++/// Balanced slices targeting `chars`, without cutting controls; an oversized control stays whole in its own slice.
+ pub fn split(a: Allocator, input: []const u8, chars: u32) Allocator.Error![]const []const u8 {
++ const spans = try drama_spans.collect(a, input);
++ defer a.free(spans);
++ const protection: Protection = .{ .base = input.ptr, .spans = spans };
+ var out: std.ArrayList([]const u8) = .empty;
+ var run: std.ArrayList([]const u8) = .empty;
+ var at: usize = 0;
+- while (nextPiece(input, &at, .sentence, chars)) |piece| {
++ while (nextPiece(input, &at, .sentence, chars, protection)) |piece| {
+ if (count(piece) <= chars) {
+ try run.append(a, piece);
+ continue;
+@@ -28,7 +33,7 @@ pub fn split(a: Allocator, input: []const u8, chars: u32) Allocator.Error![]cons
+ try balance(a, &out, run.items, chars);
+ run.clearRetainingCapacity();
+ // a sentence over `chars` is cut at ``; : ,``, then spaces, then anywhere, into segments of its own
+- try pack(a, &out, piece, .clause, chars);
++ try pack(a, &out, piece, .clause, chars, protection);
+ }
+ try balance(a, &out, run.items, chars);
+ return out.items;
+@@ -106,15 +111,17 @@ pub fn gapBetween(left: []const u8, right: []const u8) []const u8 {
+ return left.ptr[left.len .. @intFromPtr(right.ptr) - @intFromPtr(left.ptr)];
+ }
+
+-/// Packs `text`'s pieces at `level` greedily into segments of at most `chars`; a longer piece is cut one level finer.
+-fn pack(a: Allocator, out: *std.ArrayList([]const u8), text: []const u8, level: Level, chars: u32) Allocator.Error!void {
++/// Packs free text within `chars`; the final level emits an oversized protected atom without splitting it.
++fn pack(a: Allocator, out: *std.ArrayList([]const u8), text: []const u8, level: Level, chars: u32, protection: Protection) Allocator.Error!void {
+ var current: ?[]const u8 = null;
+ var at: usize = 0;
+- while (nextPiece(text, &at, level, chars)) |piece| {
++ while (nextPiece(text, &at, level, chars, protection)) |piece| {
+ if (count(piece) > chars) {
+ if (current) |c| try out.append(a, c);
+ current = null;
+- try pack(a, out, piece, @fromBackingInt(@intCast(@backingInt(level) + 1)), chars);
++ if (level == .any) {
++ try out.append(a, piece);
++ } else try pack(a, out, piece, @fromBackingInt(@intCast(@backingInt(level) + 1)), chars, protection);
+ } else if (current) |c| {
+ const joined = c.ptr[0 .. @intFromPtr(piece.ptr) + piece.len - @intFromPtr(c.ptr)];
+ if (count(joined) <= chars) {
+@@ -139,10 +146,11 @@ pub fn turns(left: []const u8, gap: []const u8, right: []const u8) bool {
+
+ /// `text` ends in a question mark, closing quotes and brackets aside.
+ pub fn question(text: []const u8) bool {
+- return lastMark(text) == '?';
++ const cp = lastMark(text);
++ return cp == '?' or cp == 0xff1f;
+ }
+
+-/// `text`, trailing white space, closing quotes and brackets aside, ends in `.` `!` `?` or `…`.
++/// `text`, trailing white space and closing quotes/brackets aside, ends in ASCII or CJK sentence punctuation.
+ pub fn endsSentence(text: []const u8) bool {
+ return terminal(lastMark(trim(text)) orelse return false);
+ }
+@@ -200,14 +208,18 @@ fn count(text: []const u8) usize {
+ return std.unicode.utf8CountCodepoints(text) catch unreachable;
+ }
+
+-/// Ends a sentence when followed by white space or the end; closing quotes and brackets stay with it.
++/// ASCII or CJK sentence punctuation; ASCII additionally needs white space or the end at the split point.
+ fn terminal(cp: u21) bool {
+- return cp == '.' or cp == '!' or cp == '?' or cp == 0x2026;
++ return cp == '.' or cp == '!' or cp == '?' or cp == 0x2026 or cjkTerminal(cp);
++}
++
++fn cjkTerminal(cp: u21) bool {
++ return cp == 0x3002 or cp == 0xff01 or cp == 0xff1f or cp == 0xff1b;
+ }
+
+ fn closer(cp: u21) bool {
+ return switch (cp) {
+- '"', '\'', ')', ']', 0x201d, 0x2019, 0xbb => true,
++ '"', '\'', ')', ']', 0x201d, 0x2019, 0xbb, 0x300d, 0x300f, 0x3011, 0xff09 => true,
+ else => false,
+ };
+ }
+@@ -217,10 +229,10 @@ fn newline(cp: u21) bool {
+ }
+
+ /// The next non-empty piece of `text` from `at.*`, trimmed of white space, with `at.*` moved past it.
+-fn nextPiece(text: []const u8, at: *usize, level: Level, chars: u32) ?[]const u8 {
++fn nextPiece(text: []const u8, at: *usize, level: Level, chars: u32, protection: Protection) ?[]const u8 {
+ while (at.* < text.len) {
+ const begin = at.*;
+- const end = cut(text, begin, level, chars);
++ const end = cut(text, begin, level, chars, protection);
+ at.* = end;
+ const piece = trim(text[begin..end]);
+ if (piece.len > 0) return piece;
+@@ -229,25 +241,45 @@ fn nextPiece(text: []const u8, at: *usize, level: Level, chars: u32) ?[]const u8
+ }
+
+ /// Where the piece starting at `begin` ends (its cut point, or the end of `text`).
+-fn cut(text: []const u8, begin: usize, level: Level, chars: u32) usize {
++fn cut(text: []const u8, begin: usize, level: Level, chars: u32, protection: Protection) usize {
+ var it = std.unicode.Utf8View.initUnchecked(text[begin..]).iterator();
+ var seen: usize = 0;
+- while (it.nextCodepoint()) |cp| {
++ const offset = @intFromPtr(text.ptr) - @intFromPtr(protection.base);
++ var atom: usize = 0;
++ while (atom < protection.spans.len and protection.spans[atom].end <= offset + begin) atom += 1;
++ while (it.i < text.len - begin) {
++ const here = begin + it.i;
++ if (atom < protection.spans.len and protection.spans[atom].start == offset + here) {
++ const end = protection.spans[atom].end - offset;
++ const n = count(text[here..end]);
++ if (level == .any and seen > 0 and seen + n > chars) return here;
++ seen += n;
++ it.i = end - begin;
++ atom += 1;
++ if (level == .any and seen >= chars) return end;
++ continue;
++ }
++ const cp = it.nextCodepoint().?;
+ seen += 1;
+ switch (level) {
+ .sentence => {
+ if (newline(cp)) return begin + it.i;
+ if (!terminal(cp)) continue;
+ var after = it;
++ var cjk = cjkTerminal(cp);
+ while (after.peek(1).len > 0) {
+ const next = std.unicode.utf8Decode(after.peek(1)) catch unreachable;
+ if (!terminal(next) and !closer(next)) break;
++ cjk = cjk or cjkTerminal(next);
+ _ = after.nextCodepoint();
+ }
+ it = after;
+- if (endsWord(text[begin..], it.i)) return begin + it.i;
++ if (cjk or endsWord(text[begin..], it.i)) return begin + it.i;
++ },
++ .clause => {
++ const cjk = cp == 0xff0c or cp == 0xff1a or cp == 0x3001;
++ if (cjk or ((cp == ';' or cp == ':' or cp == ',') and endsWord(text[begin..], it.i))) return begin + it.i;
+ },
+- .clause => if ((cp == ';' or cp == ':' or cp == ',') and endsWord(text[begin..], it.i)) return begin + it.i,
+ .space => if (drama_prompt.isWhiteSpace(cp)) return begin + it.i,
+ .any => if (seen == chars) return begin + it.i,
+ }
+@@ -388,7 +420,7 @@ test "no segment is ever longer than the limit, and there are never more than ne
+ var greedy: usize = 0;
+ var current: usize = 0;
+ var at: usize = 0;
+- while (nextPiece(text.items, &at, .sentence, chars)) |piece| {
++ while (nextPiece(text.items, &at, .sentence, chars, .{ .base = text.items.ptr, .spans = &.{} })) |piece| {
+ const n = count(piece);
+ if (current > 0 and current + 1 + n <= chars) current += 1 + n else {
+ greedy += 1;
+@@ -408,6 +440,56 @@ test "a sentence longer than the limit is cut at clause marks, then spaces, then
+ try expectSplit("\u{3042}\u{3044}\u{3046}\u{3048}\u{304a}", 2, &.{ "\u{3042}\u{3044}", "\u{3046}\u{3048}", "\u{304a}" });
+ }
+
++test "protected chunks keep a paired direction whole when it exceeds the target" {
++ const input = "One. Two. Three.";
++ for ([_]u32{ 1, 20, input.len - 1, input.len }) |chars| try expectSplit(input, chars, &.{input});
++ try expectSplit("AB" ++ input ++ "CD", 2, &.{ "AB", input, "CD" });
++ try expectSplit("[laughs softly] Hello.", 10, &.{ "[laughs softly]", "Hello." });
++ try expectSplit("[laughs][pause]", 1, &.{ "[laughs]", "[pause]" });
++ try expectSplit("<|speaker:0|> ab", 1, &.{ "<|speaker:0|>", "a", "b" });
++}
++
++test "protected chunks preserve nested spans and recover after malformed openers" {
++ const nested = "One. Two. [pause] Three.";
++ try expectSplit(nested ++ " After.", 10, &.{ nested, "After." });
++ try expectSplit("one. two.", 10, &.{ "one.", "two." });
++ try expectSplit("[open Quiet.", 5, &.{ "[open", "Quiet." });
++ try expectSplit("[outer [inner] tail] End.", 5, &.{ "[outer [inner] tail]", "End." });
++}
++
++test "protected chunks stay in the original input and only indivisible spans may exceed the target" {
++ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ const input = "ab\u{4e59}\u{4e19}\u{4e01}\u{3002}cd [laughs softly] ef";
++ const spans = try drama_spans.collect(a, input);
++ for ([_]u32{ 1, 2, 7, 20 }) |chars| {
++ const got = try split(a, input, chars);
++ var end: usize = 0;
++ for (got) |segment| {
++ const start = @intFromPtr(segment.ptr) - @intFromPtr(input.ptr);
++ try std.testing.expect(start >= end and start + segment.len <= input.len);
++ try std.testing.expectEqual(@as(usize, 0), trim(input[end..start]).len);
++ end = start + segment.len;
++ if (count(segment) > chars) {
++ const whole = for (spans) |atom| {
++ if (start == atom.start and end == atom.end) break true;
++ } else false;
++ try std.testing.expect(whole);
++ }
++ }
++ try std.testing.expectEqual(@as(usize, 0), trim(input[end..]).len);
++ }
++}
++
++test "CJK sentence and clause punctuation does not need a following space" {
++ try expectSplit("\u{4f60}\u{597d}\u{3002}\u{4e16}\u{754c}\u{ff01}\u{518d}\u{89c1}\u{ff1f}\u{7ed3}\u{675f}\u{3002}", 6, &.{ "\u{4f60}\u{597d}\u{3002}\u{4e16}\u{754c}\u{ff01}", "\u{518d}\u{89c1}\u{ff1f}\u{7ed3}\u{675f}\u{3002}" });
++ try expectSplit("\u{7532}\u{4e59}\u{ff0c}\u{4e19}\u{4e01}\u{3001}\u{620a}\u{5df1}\u{ff1a}\u{5e9a}\u{8f9b}\u{3002}", 5, &.{ "\u{7532}\u{4e59}\u{ff0c}", "\u{4e19}\u{4e01}\u{3001}", "\u{620a}\u{5df1}\u{ff1a}", "\u{5e9a}\u{8f9b}\u{3002}" });
++ try expectSplit("\u{300c}\u{7532}\u{3002}\u{300d}\u{4e59}\u{ff1b}\u{4e19}\u{ff01}", 4, &.{ "\u{300c}\u{7532}\u{3002}\u{300d}", "\u{4e59}\u{ff1b}\u{4e19}\u{ff01}" });
++ try expectSplit("\u{7532}.\u{ff1f}\u{4e59}\u{3002}", 4, &.{ "\u{7532}.\u{ff1f}", "\u{4e59}\u{3002}" });
++ try std.testing.expect(question("\u{4f60}\u{597d}\u{ff1f}\u{300d}"));
++}
++
+ test "the segments header lists where each segment after the first starts, and its largest value fits its buffer" {
+ var buf: [segments_header_max]u8 = undefined;
+ try std.testing.expectEqualStrings("3;2048,6144", segmentsHeader(&buf, &.{ 0, 2048, 6144 }));
+diff --git a/zig/src/server/drama_shift.zig b/zig/src/server/drama_shift.zig
+index 7171ac3a..43fda1b3 100644
+--- a/zig/src/server/drama_shift.zig
++++ b/zig/src/server/drama_shift.zig
+@@ -2,6 +2,7 @@
+ const std = @import("std");
+ const drama_prompt = @import("drama_prompt.zig");
+ const drama_long = @import("drama_long.zig");
++const drama_spans = @import("drama_spans.zig");
+ const drama_joins = @import("drama_joins.zig");
+ const unicode = @import("tokenizer").unicode;
+ const Allocator = std.mem.Allocator;
+@@ -15,6 +16,9 @@ pub const Marked = struct { text: []const u8, cuts: []const usize = &.{}, droppe
+ /// `input` without its marks and the white space after each; with `honour`, its cuts (`read`'s rule, in the docs).
+ pub fn read(a: Allocator, input: []const u8, honour: bool) Allocator.Error!Marked {
+ if (std.mem.indexOf(u8, input, mark) == null) return .{ .text = input };
++ const protected = if (honour) try drama_spans.collect(a, input) else &.{};
++ defer if (honour) a.free(protected);
++ var atom: usize = 0;
+ var text: std.ArrayList(u8) = .empty;
+ var cuts: std.ArrayList(usize) = .empty;
+ var dropped: usize = 0;
+@@ -28,6 +32,11 @@ pub fn read(a: Allocator, input: []const u8, honour: bool) Allocator.Error!Marke
+ at = afterWhiteSpace(input, at + mark.len);
+ }
+ if (!honour) continue;
++ while (atom < protected.len and protected[atom].end <= found) atom += 1;
++ if (atom < protected.len and protected[atom].start < found) {
++ dropped += marks;
++ continue;
++ }
+ // the segments either side: from the cut before, and up to the next mark
+ const before = text.items[if (cuts.items.len > 0) cuts.items[cuts.items.len - 1] else 0..];
+ const after = input[at .. std.mem.indexOfPos(u8, input, at, mark) orelse input.len];
+@@ -64,7 +73,7 @@ fn hasWords(text: []const u8) bool {
+ /// A long-form input's segments, and `shifts[i]` (empty without a cut): a mark stands right before segment i.
+ pub const Segments = struct { segments: []const []const u8, shifts: []const bool = &.{} };
+
+-/// `marked`'s segments of at most `chars`.
++/// `marked`'s segments targeting `chars`, keeping indivisible controls whole.
+ pub fn split(a: Allocator, marked: Marked, chars: u32) Allocator.Error!Segments {
+ if (marked.cuts.len == 0) return .{ .segments = try drama_long.split(a, marked.text, chars) };
+ var segments: std.ArrayList([]const u8) = .empty;
+@@ -210,6 +219,15 @@ test "a mark needs words on both sides: punctuation or cues alone are no segment
+ try expectRead("One. \u{b6} [small laugh] Two.", "One. [small laugh] Two.", &.{5}, 0);
+ }
+
++test "topic shifts never cut protected spans, but a following ordinary shift still counts" {
++ try expectRead("One. \u{b6} Two.", "One. Two.", &.{}, 1);
++ try expectRead("[laughs. \u{b6} softly] End.", "[laughs. softly] End.", &.{}, 1);
++ const before = "One. Two. Three. End. ";
++ try expectRead("One. Two. \u{b6} Three. End. \u{b6} Next.", before ++ "Next.", &.{before.len}, 1);
++ try expectRead("One. \u{b6} Two.", "One. Two.", &.{15}, 0);
++ try expectRead("\u{4f60}\u{597d}\u{3002}\u{b6}\u{4e16}\u{754c}\u{3002}", "\u{4f60}\u{597d}\u{3002}\u{4e16}\u{754c}\u{3002}", &.{9}, 0);
++}
++
+ test "words are Unicode letters and numbers; full-width punctuation and symbols are none" {
+ // full-width marks, math and currency signs, an emoji, Latin-1's inverted marks
+ for ([_][]const u8{ "\u{ff01}\u{ff01}\u{ff01}", "\u{d7}", "\u{20ac} \u{2192}", "\u{1f600}", "\u{bf}\u{a1}", "\u{3002}\u{300c}\u{300d}" }) |rest| {
+diff --git a/zig/src/server/drama_spans.zig b/zig/src/server/drama_spans.zig
+new file mode 100644
+index 00000000..86905d1d
+--- /dev/null
++++ b/zig/src/server/drama_spans.zig
+@@ -0,0 +1,128 @@
++//! Indivisible speech text: special tokens, balanced brackets and correctly nested paired tags.
++const std = @import("std");
++const Allocator = std.mem.Allocator;
++
++pub const Span = struct { start: usize, end: usize };
++const Tag = struct { start: usize, end: usize, name: []const u8, closing: bool };
++
++/// Sorted, disjoint byte ranges; adjacent controls remain separate atoms and all bytes stay in the caller's input.
++pub fn collect(a: Allocator, input: []const u8) Allocator.Error![]Span {
++ var atoms: std.ArrayList(Span) = .empty;
++ defer atoms.deinit(a);
++ var brackets: std.ArrayList(usize) = .empty;
++ defer brackets.deinit(a);
++ var i: usize = 0;
++ while (i < input.len) {
++ if (specialEnd(input, i)) |end| {
++ try atoms.append(a, .{ .start = i, .end = end });
++ i = end;
++ continue;
++ }
++ if (input[i] == '[') try brackets.append(a, i) else if (input[i] == ']') {
++ if (brackets.pop()) |start| try atoms.append(a, .{ .start = start, .end = i + 1 });
++ }
++ i += 1;
++ }
++ merge(&atoms);
++
++ var spans: std.ArrayList(Span) = .empty;
++ errdefer spans.deinit(a);
++ try spans.appendSlice(a, atoms.items);
++ var tags: std.ArrayList(Tag) = .empty;
++ defer tags.deinit(a);
++ var atom: usize = 0;
++ i = 0;
++ while (i < input.len) {
++ if (atom < atoms.items.len and i == atoms.items[atom].start) {
++ i = atoms.items[atom].end;
++ atom += 1;
++ continue;
++ }
++ if (tagAt(input, i)) |tag| {
++ if (!tag.closing) {
++ try tags.append(a, tag);
++ } else if (tags.pop()) |open| {
++ if (std.mem.eql(u8, open.name, tag.name)) {
++ try spans.append(a, .{ .start = open.start, .end = tag.end });
++ } else tags.clearRetainingCapacity();
++ }
++ i = tag.end;
++ } else i += 1;
++ }
++ merge(&spans);
++ return spans.toOwnedSlice(a);
++}
++
++fn specialEnd(input: []const u8, start: usize) ?usize {
++ if (!std.mem.startsWith(u8, input[start..], "<|")) return null;
++ var i = start + 2;
++ while (i < input.len and input[i] != '|' and input[i] != '>') : (i += 1) {
++ if (input[i] == '<') return null;
++ }
++ return if (std.mem.startsWith(u8, input[i..], "|>")) i + 2 else null;
++}
++
++fn tagAt(input: []const u8, start: usize) ?Tag {
++ if (input[start] != '<') return null;
++ var i = start + 1;
++ const closing = i < input.len and input[i] == '/';
++ if (closing) i += 1;
++ const first = i;
++ if (i == input.len or !std.ascii.isAlphabetic(input[i])) return null;
++ i += 1;
++ while (i < input.len and (std.ascii.isAlphanumeric(input[i]) or input[i] == '_' or input[i] == '-')) i += 1;
++ if (i == input.len or input[i] != '>') return null;
++ return .{ .start = start, .end = i + 1, .name = input[first..i], .closing = closing };
++}
++
++fn before(_: void, left: Span, right: Span) bool {
++ return left.start < right.start or (left.start == right.start and left.end > right.end);
++}
++
++fn merge(spans: *std.ArrayList(Span)) void {
++ std.mem.sort(Span, spans.items, {}, before);
++ var n: usize = 0;
++ for (spans.items) |span| {
++ if (n > 0 and span.start < spans.items[n - 1].end) {
++ spans.items[n - 1].end = @max(spans.items[n - 1].end, span.end);
++ } else {
++ spans.items[n] = span;
++ n += 1;
++ }
++ }
++ spans.shrinkRetainingCapacity(n);
++}
++
++fn expectSpans(input: []const u8, want: []const []const u8) !void {
++ const spans = try collect(std.testing.allocator, input);
++ defer std.testing.allocator.free(spans);
++ try std.testing.expectEqual(want.len, spans.len);
++ for (spans, want) |span, expected| try std.testing.expectEqualStrings(expected, input[span.start..span.end]);
++ for (spans, 0..) |span, i| {
++ try std.testing.expect(span.start < span.end and span.end <= input.len);
++ if (i > 0) try std.testing.expect(spans[i - 1].end <= span.start);
++ }
++}
++
++test "special tokens and adjacent or nested bracketed cues are indivisible" {
++ try expectSpans("A <|speaker:0|> [soft laugh][pause] B", &.{ "<|speaker:0|>", "[soft laugh]", "[pause]" });
++ try expectSpans("[outer [inner] tail] [next]", &.{ "[outer [inner] tail]", "[next]" });
++ try expectSpans("[<|speaker:0|> [pause]]", &.{"[<|speaker:0|> [pause]]"});
++ try expectSpans("<|a|b|> <|x> <|unterminated", &.{});
++}
++
++test "paired tags nest by exact name and ignore tag-shaped text inside bracketed cues" {
++ const nested = "One. Two. Three.";
++ try expectSpans(nested ++ " Four.", &.{ nested, "Four." });
++ try expectSpans("[literal ] Quiet.", &.{"[literal ] Quiet."});
++ try expectSpans("\u{4f60}\u{597d}\u{3002}", &.{"\u{4f60}\u{597d}\u{3002}"});
++}
++
++test "unclosed and crossed controls do not hide subsequent valid controls" {
++ try expectSpans("[unclosed [pause] Hi.", &.{ "[pause]", "Hi." });
++ try expectSpans("Hi.", &.{"Hi."});
++ try expectSpans("Hi. Bye. End.", &.{"End."});
++ try expectSpans("Hi. Hi. <1>Hi.1>", &.{});
++ try expectSpans("<|unclosed Hi.", &.{"Hi."});
++ try expectSpans("2 < 3; a > b; [] [unfinished", &.{"[]"});
++}
+diff --git a/zig/src/server/speech_long.zig b/zig/src/server/speech_long.zig
+index b174cf8a..619e0f79 100644
+--- a/zig/src/server/speech_long.zig
++++ b/zig/src/server/speech_long.zig
+@@ -44,7 +44,7 @@ pub fn plan(srv: *Server, cx: *Cx, sp: api.SpeechInfo, marked: drama_shift.Marke
+ const segments = split.segments;
+ if (segments.len <= 1) return null;
+ if (segments.len > drama_long.max_segments)
+- return cx.fail(.request, drama_prompt.error_prefix ++ " a long-form input in {d} segments of at most {d} characters exceeds the maximum of {d} segments", .{ segments.len, options.chars, drama_long.max_segments });
++ return cx.fail(.request, drama_prompt.error_prefix ++ " a long-form input in {d} segments targeting {d} characters exceeds the maximum of {d} segments", .{ segments.len, options.chars, drama_long.max_segments });
+ for (segments) |segment| {
+ if (try drama_prompt.problem(cx.a, segment)) |message| return cx.refuse(message);
+ const rows = drama_prompt.promptRows(cx.a, srv.text, segment, references) catch |e| return switch (e) {
+diff --git a/zig/src/server/speech_long_test.zig b/zig/src/server/speech_long_test.zig
+index e0d850b4..8fb9a7ba 100644
+--- a/zig/src/server/speech_long_test.zig
++++ b/zig/src/server/speech_long_test.zig
+@@ -110,9 +110,10 @@ pub fn body(a: std.mem.Allocator, input: []const u8, stream: bool) ![]const u8 {
+ fn serve(t: *Turns, cfg: server.Config, request: []const u8) !struct { s: *Served, head: []const u8 } {
+ const s = try gpa.create(Served);
+ try s.startWith(t.engine(), cfg, "HTTP/1.1", request, 2000);
+- const head = try s.readHead();
++ const head_len = (try s.readHead()).len;
++ _ = try s.readRest(); // drain before joining: a whole reply can outgrow the socket's buffers
+ s.route.join();
+- return .{ .s = s, .head = head };
++ return .{ .s = s, .head = s.got.items[0..head_len] };
+ }
+
+ pub fn done(s: *Served) void {
+@@ -338,7 +339,7 @@ test "more than 12 segments are refused before any synthesis" {
+ const r = try serve(&t, config(.{ .chars = 20 }), try body(a, try sentences(a, 13), false));
+ defer done(r.s);
+ try std.testing.expect(std.mem.indexOf(u8, r.head, " 400 ") != null);
+- try std.testing.expect(std.mem.indexOf(u8, try r.s.readRest(), "Drama 3 request: a long-form input in 13 segments of at most 20 characters exceeds the maximum of 12 segments") != null);
++ try std.testing.expect(std.mem.indexOf(u8, try r.s.readRest(), "Drama 3 request: a long-form input in 13 segments targeting 20 characters exceeds the maximum of 12 segments") != null);
+ try std.testing.expectEqual(@as(usize, 0), t.seen);
+ }
+
+@@ -383,3 +384,51 @@ test "a failure after the first segment ends a stream incomplete and a whole rep
+ }
+ }
+ }
++
++test "protected controls reach whole and streamed speech requests without a split inside them" {
++ var arena: std.heap.ArenaAllocator = .init(gpa);
++ defer arena.deinit();
++ const a = arena.allocator();
++ var text: harness.Bytes = .{};
++ const cases = [_]struct { input: []const u8, first: []const u8, second: []const u8, chars: u32 }{
++ .{ .input = "One. Two. Three. After.", .first = "One. Two. Three.", .second = "After.", .chars = 20 },
++ .{ .input = "[laughs softly] Hello.", .first = "[laughs softly]", .second = "Hello.", .chars = 10 },
++ .{ .input = "One. \u{b6} Two. After.", .first = "One. Two.", .second = "After.", .chars = 20 },
++ };
++ for (cases) |case| for ([_]bool{ false, true }) |stream| {
++ var t: Turns = .{ .turns = &.{ .{ .frames = 3 }, .{ .frames = 4 } } };
++ defer t.deinit();
++ const options: drama_long.Options = .{ .chars = case.chars, .history = 0, .shift_pause_s = 0.5 };
++ const r = try serve(&t, config(options), try body(a, case.input, stream));
++ defer done(r.s);
++ try std.testing.expect(std.mem.indexOf(u8, r.head, "HTTP/1.1 200") != null);
++ const rest = try r.s.readRest();
++ try std.testing.expect(rest.len > 0);
++ try std.testing.expectEqual(@as(usize, 2), t.seen);
++ for ([_][]const u8{ case.first, case.second }, t.prompts.items) |segment, prompt|
++ try std.testing.expectEqualSlices(u32, try drama_prompt.encode(a, text.text(), segment), prompt);
++ try std.testing.expectEqualSlices(bool, &.{ stream, false }, t.streamed.items);
++ const metadata = if (stream) rest else r.s.got.items[0..r.head.len];
++ try std.testing.expect(std.mem.indexOf(u8, metadata, "X-Long-Form-Segments: 2;") != null);
++ try std.testing.expect(std.mem.indexOf(u8, metadata, "X-Pause-Spans:") == null);
++ if (stream) try std.testing.expect(std.mem.endsWith(u8, rest, "\r\n\r\n"));
++ };
++}
++
++test "an indivisible span above the character target still obeys the engine prompt ceiling" {
++ var arena: std.heap.ArenaAllocator = .init(gpa);
++ defer arena.deinit();
++ const a = arena.allocator();
++ var text: harness.Bytes = .{};
++ const span = "One. Two. Three.";
++ const ceiling: u32 = @intCast((try drama_prompt.encode(a, text.text(), span)).len - 1);
++ for ([_][]const u8{ span, span ++ " After." }) |input| {
++ var t: Turns = .{ .turns = &.{}, .max_prompt = ceiling };
++ defer t.deinit();
++ const r = try serve(&t, config(.{ .chars = 1 }), try body(a, input, false));
++ defer done(r.s);
++ try std.testing.expect(std.mem.indexOf(u8, r.head, "HTTP/1.1 400") != null);
++ try std.testing.expect(std.mem.indexOf(u8, try r.s.readRest(), "exceeds the maximum allowed length") != null);
++ try std.testing.expectEqual(@as(usize, 0), t.seen);
++ }
++}
+diff --git a/zig/src/server/speech_mid_test.zig b/zig/src/server/speech_mid_test.zig
+index 6ce2c377..88f19a7c 100644
+--- a/zig/src/server/speech_mid_test.zig
++++ b/zig/src/server/speech_mid_test.zig
+@@ -25,8 +25,8 @@ fn serve(t: *Turns, cfg: server.Config, deadline: ?[]const u8, request: []const
+ const first = if (deadline) |d| try std.fmt.bufPrint(&line, "POST /v1/audio/speech HTTP/1.1\r\nX-Deadline-S: {s}", .{d}) else "POST /v1/audio/speech HTTP/1.1";
+ try s.open(t.engine(), cfg, first, request, 2000);
+ _ = try s.readHead();
+- s.route.join();
+ _ = try s.readRest();
++ s.route.join();
+ // read whole first: reading more can move the bytes a head slice points into
+ const all = s.got.items;
+ return .{ .s = s, .head = all[0 .. std.mem.indexOf(u8, all, "\r\n\r\n").? + 4], .all = all };
+diff --git a/zig/src/server/speech_redraw_test.zig b/zig/src/server/speech_redraw_test.zig
+index fbb5e1fe..c661c972 100644
+--- a/zig/src/server/speech_redraw_test.zig
++++ b/zig/src/server/speech_redraw_test.zig
+@@ -24,9 +24,10 @@ fn serve(t: *Turns, cfg: server.Config, deadline: ?[]const u8, request: []const
+ const s = try gpa.create(harness.Served);
+ const first = if (deadline) |d| try std.fmt.bufPrint(&line, "POST /v1/audio/speech HTTP/1.1\r\nX-Deadline-S: {s}", .{d}) else "POST /v1/audio/speech HTTP/1.1";
+ try s.open(t.engine(), cfg, first, request, 2000);
+- const head = try s.readHead();
++ const head_len = (try s.readHead()).len;
++ _ = try s.readRest(); // drain before joining: a whole reply can outgrow the socket's buffers
+ s.route.join();
+- return .{ .s = s, .head = head };
++ return .{ .s = s, .head = s.got.items[0..head_len] };
+ }
+
+ fn has(haystack: []const u8, needle: []const u8) bool {
+@@ -423,7 +424,7 @@ test "long-form controls: a later segment that opens like a director's note keep
+ try std.testing.expectEqualSlices(u32, want, try redrawnSecond(a, "Sentence 00 is ok, and it is long enough.\\nDirection: d\\nLine: Sentence 01 [pause] ok.", 45));
+ }
+
+-test "long-form controls: a bracket span that a segment cut crosses is dropped whole, from both segments" {
++test "long-form controls: a bracket cue stays in one segment and is dropped whole on redraw" {
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ const a = arena.allocator();
+@@ -431,7 +432,8 @@ test "long-form controls: a bracket span that a segment cut crosses is dropped w
+ const input = "Sentence 00 is ok. [warmly. then] Sentence 01 ok.";
+ const segments = try drama_long.split(a, input, 30);
+ try std.testing.expectEqual(@as(usize, 2), segments.len);
+- try std.testing.expect(std.mem.endsWith(u8, segments[0], "[warmly.") and std.mem.startsWith(u8, segments[1], "then]"));
++ try std.testing.expectEqualStrings("Sentence 00 is ok.", segments[0]);
++ try std.testing.expectEqualStrings("[warmly. then] Sentence 01 ok.", segments[1]);
+ const want = try drama_prompt.encode(a, text.text(), "Sentence 01 ok.");
+ try std.testing.expectEqualSlices(u32, want, try redrawnSecond(a, input, 30));
+ }
+diff --git a/zig/src/server/speech_register_test.zig b/zig/src/server/speech_register_test.zig
+index d0bebe25..4e9d24bb 100644
+--- a/zig/src/server/speech_register_test.zig
++++ b/zig/src/server/speech_register_test.zig
+@@ -86,8 +86,9 @@ fn serve(d: *Draws, cfg: server.Config, deadline: ?[]const u8, request: []const
+ try s.open(d.engine(), cfg, first, request, 2000);
+ // the body's read may move the bytes the head lies in
+ const head = try d.a.dupe(u8, try s.readHead());
++ const rest = try s.readRest(); // drain before joining: the 16 kHz replies outgrow the socket's buffers
+ s.route.join();
+- return .{ .s = s, .head = head, .body = try s.readRest() };
++ return .{ .s = s, .head = head, .body = rest };
+ }
+
+ fn config(register: speech_register.Policy, silence: speech_redraw.Policy) server.Config {
+diff --git a/zig/src/server/speech_stream_test.zig b/zig/src/server/speech_stream_test.zig
+index 030dd3b4..5cdcad00 100644
+--- a/zig/src/server/speech_stream_test.zig
++++ b/zig/src/server/speech_stream_test.zig
+@@ -265,8 +265,8 @@ test "audio published before the route reads it goes out as one chunk" {
+ try s.start(&script, "HTTP/1.1", stream_body, 2000);
+ defer s.deinit();
+ _ = try s.readHead();
+- s.join(&script);
+ const rest = try s.readRest();
++ s.join(&script);
+ try std.testing.expect(std.mem.startsWith(u8, rest, "3000\r\n") and std.mem.endsWith(u8, rest, "\r\n0\r\n\r\n"));
+ try std.testing.expectEqualStrings(&pcm(3), rest["3000\r\n".len .. rest.len - "\r\n0\r\n\r\n".len]);
+ }
+@@ -305,8 +305,8 @@ test "audio cut short by the frame limit or the silence guard ends the stream wi
+ try s.start(&script, "HTTP/1.1", stream_body, 2000);
+ defer s.deinit();
+ try expectHeader(try s.readHead(), "Trailer: X-Finish-Reason\r\n");
+- s.join(&script);
+ const rest = try s.readRest();
++ s.join(&script);
+ var buf: [64]u8 = undefined;
+ const end = try std.fmt.bufPrint(&buf, "\r\n0\r\nX-Finish-Reason: {s}\r\n\r\n", .{@tagName(reason)});
+ try std.testing.expect(std.mem.startsWith(u8, rest, "2000\r\n") and std.mem.endsWith(u8, rest, end));
+--
+2.46.0
+
diff --git a/engine/nvfp4/patches/README.md b/engine/nvfp4/patches/README.md
new file mode 100644
index 0000000..70cc1e1
--- /dev/null
+++ b/engine/nvfp4/patches/README.md
@@ -0,0 +1,27 @@
+# CUDA NVFP4 patch series
+
+These native patches apply in filename order to the unchanged
+[`../UPSTREAM`](../UPSTREAM) pin. The builder checks that `SHA256SUMS` lists every
+selected patch exactly once, verifies its digest, then applies the series.
+
+| Patch | Scope | Source PR |
+|---|---|---|
+| `0001-protected-speech-chunks.patch` | Protected markup and CJK long-form boundaries; test fixture drain ordering | [TensorFold #5](https://github.com/plotarmordev/TensorFold/pull/5) |
+
+Protected constructs are complete model special tokens, balanced bracketed
+spans and correctly nested, exact-name ASCII tags without attributes. Unclosed
+or mismatched markup does not protect unrelated following text. Adjacent
+controls remain separate atoms. Segment slices retain their original backing
+buffer, and only a whole protected atom may exceed the code-point target.
+
+CPU validation: 364 passed, four skipped; goldens have zero unexpected
+differences. Independent adversarial testing exercised 1,680 combinations of
+markup, Unicode and character targets. Six existing socket-fixture ordering
+repairs let the macOS CPU suite drain replies before joining server threads;
+production transport is unchanged. CUDA host compilation is still blocked on
+macOS by the base's `O.DIRECT` use. No device/model or listening qualification
+is claimed by these CPU checks.
+
+The [public MLX chunker](https://github.com/Sogni-AI/drama3-mlx/blob/ecc235039ccd5f982f66a6982184f57432354ab4/drama3_mlx/chunking.py)
+is a behavior reference. This is an independent native implementation under
+the recipe's Apache-2.0 license; model weights keep their separate license.
diff --git a/engine/nvfp4/patches/SHA256SUMS b/engine/nvfp4/patches/SHA256SUMS
new file mode 100644
index 0000000..ec0229f
--- /dev/null
+++ b/engine/nvfp4/patches/SHA256SUMS
@@ -0,0 +1 @@
+c0553b2cfd231ddbbe6390edfe49546db5bef084d5bb6a5009542ccccfdd2fb9 0001-protected-speech-chunks.patch
diff --git a/engine/patches/README.md b/engine/patches/README.md
index 36e20a4..6a1f7bb 100644
--- a/engine/patches/README.md
+++ b/engine/patches/README.md
@@ -11,6 +11,9 @@ engine loads are under the Fish Audio Community License v1.0 and are downloaded
`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.
+
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.
diff --git a/recipe/README.md b/recipe/README.md
index e0aaf6b..d9b9f3b 100644
--- a/recipe/README.md
+++ b/recipe/README.md
@@ -7,6 +7,9 @@ On Apple silicon use [`pilot_macos.py`](README-macos.md), with the same explicit
selection and checkpoint manifests. It uses native Metal and owned process groups, not systemd.
Its NVFP4 engine pin is separate from CUDA; the full-model pin remains unchanged.
+The Linux NVFP4 build applies `engine/nvfp4/patches` after its pinned base. Its long-form
+splitter keeps delivery markup intact; see [CUDA NVFP4 long speech](../README.md#cuda-nvfp4-long-speech).
+
```sh
python3 recipe/pilot.py --weights nvfp4 \
--runtime-dir "$RUNTIME_DIR" \
From cb20bdaf6e0637bbb5860dca0c05cc1e3e703ef4 Mon Sep 17 00:00:00 2001
From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
Date: Sun, 25 Oct 2026 12:10:18 +0800
Subject: [PATCH 4/7] Add request sampling controls to all pinned Drama engines
---
CHANGELOG.md | 3 +
README.md | 14 +-
.../0004-speech-sampling-controls.patch | 539 +++++++++++++++
.../0005-sampling-json-boundaries.patch | 90 +++
engine/nvfp4/macos/patches/SHA256SUMS | 2 +
.../0002-speech-sampling-controls.patch | 630 ++++++++++++++++++
engine/nvfp4/patches/README.md | 5 +
engine/nvfp4/patches/SHA256SUMS | 1 +
.../0013-speech-sampling-controls.patch | 536 +++++++++++++++
.../0014-sampling-json-boundaries.patch | 90 +++
engine/patches/README.md | 6 +
engine/patches/SHA256SUMS | 2 +
recipe/README-sampling.md | 57 ++
13 files changed, 1969 insertions(+), 6 deletions(-)
create mode 100644 engine/nvfp4/macos/patches/0004-speech-sampling-controls.patch
create mode 100644 engine/nvfp4/macos/patches/0005-sampling-json-boundaries.patch
create mode 100644 engine/nvfp4/patches/0002-speech-sampling-controls.patch
create mode 100644 engine/patches/0013-speech-sampling-controls.patch
create mode 100644 engine/patches/0014-sampling-json-boundaries.patch
create mode 100644 recipe/README-sampling.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bfa6c8d..820b66e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,9 @@
### Added
+- Per-request HTTP speech `temperature`, `top_p`, `top_k` and exact unsigned 64-bit
+ `seed` on all pinned profiles, with strict validation and propagation through
+ streaming, references and redraws. Omitted/null values retain existing defaults.
- A checksummed CUDA NVFP4 engine patch series without changing its pinned upstream or weights.
Model-free build tests exercise real patch application, selection on Linux/macOS, corruption
refusal, repeat builds and preservation of modified checkouts. Manifests must cover each
diff --git a/README.md b/README.md
index 35e647b..3440f74 100644
--- a/README.md
+++ b/README.md
@@ -123,7 +123,9 @@ the license's Section III(c), which needs a separate license from Fish Audio.
`POST /v1/audio/speech` takes `model` (`fishaudio/drama-3`), `input` (at most 4096 characters), `voice`
(`default`, or a name loaded with `--voices`), `response_format` (`wav` or `pcm`) and `stream` (`true` needs
-`pcm`). Request-supplied references and other synthesis modifiers are refused. `GET /v1/voices` lists the voices.
+`pcm`). Optional `temperature`, `top_p`, `top_k` and exact integer `seed` select
+request-local sampling; see [ranges, defaults and redraw behavior](recipe/README-sampling.md).
+Other synthesis modifiers are refused. `GET /v1/voices` lists the voices.
The Linux CUDA NVFP4 engine accepts its encoder's `drama3-voice` catalogs and request `references`
as codec codes (not audio). The Mac NVFP4 engine instead uses attested `drama3-http-voice/1`
catalogs and refuses request-supplied references; see the [Mac voice commands](recipe/README-macos.md#attested-reference-voices).
@@ -157,11 +159,9 @@ See the [CUDA patch index](engine/nvfp4/patches/README.md) for scope and tests.
### Full-model engine
-The 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: batched prefill is on by default (`TF_DRAMA_PREFILL_ROWS=1`
-restores upstream's single-row prefill) and the replay fix is a bug fix. The patches stack and apply in order;
-each one's notes are in the engine tree's `CHANGELOG.md` or `docs/`.
+These patches build the full-model engine (`--weights full`) and apply in order.
+The table lists each feature's activation; HTTP sampling fields need no additional
+launch flag. Each patch's notes are in the engine tree's `CHANGELOG.md` or `docs/`.
| Patch | Adds | Opt in with | Upstream |
|---|---|---|---|
@@ -177,6 +177,8 @@ each one's notes are in the engine tree's `CHANGELOG.md` or `docs/`.
| 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.
diff --git a/engine/nvfp4/macos/patches/0004-speech-sampling-controls.patch b/engine/nvfp4/macos/patches/0004-speech-sampling-controls.patch
new file mode 100644
index 0000000..680d71d
--- /dev/null
+++ b/engine/nvfp4/macos/patches/0004-speech-sampling-controls.patch
@@ -0,0 +1,539 @@
+From 43367973c989ff8d19a6d8ffa2669c4a530bee80 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 | 23 ++--
+ 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, 302 insertions(+), 15 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 50a2c7ad..4f62fc5d 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).
++
+ - Drama 3 on Metal reads the pruned NVFP4 checkpoint's packed E2M1 experts, block-16 E4M3 scales and F32 global scales without requantizing or expanding the experts. Routing masks dropped original IDs before softmax and maps selected IDs to compact slots. The full FP8-expert path retains its existing arithmetic.
+ - Metal Drama speech supports opt-in semantic classifier-free guidance with shared weights and independent conditional/unconditional backbone state. `--cfg-gamma`, `--cfg-allow-request-gamma`, `--cfg-force-guided` and `--cfg-batched` preserve builtin unguided voices, validate request gamma, and reset guidance on cancellation or reference changes. Paired mode submits both states in one command buffer, not a vectorized GEMM; admission includes the extra state. CUDA guidance is not enabled by this change.
+
+diff --git a/README.md b/README.md
+index cf69ddec..92896199 100644
+--- a/README.md
++++ b/README.md
+@@ -178,7 +178,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. |
+ | Drama 3 pruned NVFP4, speech | `hf download plotarmordev/drama-3-NVFP4-Pruned --revision 494db08481db952f834b2241bce63fb3dfa72554 --local-dir drama-3-NVFP4-Pruned` | Native Metal; roughly 21 GiB checkpoint plus runtime state. Not a minimum-RAM or cross-backend quality claim. | Packed experts, BF16 dense/tower weights and FP32 accumulation; no conversion. Use `serve drama-3-NVFP4-Pruned --cfg-gamma 1.5 --cfg-batched` with an attested catalog voice. |
+
+ ### GLM-5.3: one machine or two?
+@@ -298,7 +298,7 @@ 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-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 19e55165..24673a68 100644
+--- a/docs/speech-redraws.md
++++ b/docs/speech-redraws.md
+@@ -12,12 +12,18 @@ enabled silence guard; `--silence-redraw-controls` and the
+
+ ## 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
+@@ -119,7 +125,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
+ end as `silence` 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 3fdd321a..4d46119e 100644
+--- a/zig/src/server/root.zig
++++ b/zig/src/server/root.zig
+@@ -53,6 +53,7 @@ test {
+ _ = @import("drama_cfg.zig");
+ if (comptime @import("builtin").os.tag != .windows) _ = @import("speech_cfg_test.zig");
+ if (comptime @import("builtin").os.tag != .windows) _ = @import("speech_stream_test.zig"); // over a Unix socket pair
++ if (comptime @import("builtin").os.tag != .windows) _ = @import("speech_sampling_test.zig");
+ _ = @import("drama_voice_tool.zig");
+ _ = @import("speech_redraw.zig");
+ if (comptime @import("builtin").os.tag != .windows) _ = @import("speech_redraw_test.zig"); // over a Unix socket pair
+diff --git a/zig/src/server/speech.zig b/zig/src/server/speech.zig
+index 2897cd0e..07b75904 100644
+--- a/zig/src/server/speech.zig
++++ b/zig/src/server/speech.zig
+@@ -10,6 +10,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 drama_cfg = @import("drama_cfg.zig");
+@@ -30,7 +31,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,
+@@ -44,7 +45,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 };
+
+ /// One whole take: its audio, why it ended, and whether it ended in a mid-utterance silence.
+ const Take = struct { pcm: []f32, reason: api.Reason, silence_mid: bool = false, trip: bool = false, jump: ?f64 = null };
+@@ -89,6 +90,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});
+@@ -107,6 +110,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 });
+@@ -125,9 +129,9 @@ 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});
+ };
+- if (try @import("drama_voice.zig").requestPrompt(cx, srv.text, sp, input, refs)) |made| return .{ .input = input, .prompt = made.prompt, .frames = made.frames, .refs = refs, .reference_rows = made.reference_rows, .seed = api.seedFor(made.prompt, srv.config.seed_salt), .format = format, .stream = stream };
++ if (try @import("drama_voice.zig").requestPrompt(cx, srv.text, sp, input, refs)) |made| return .{ .input = input, .prompt = made.prompt, .frames = made.frames, .refs = refs, .reference_rows = made.reference_rows, .sampling = sampling, .format = format, .stream = stream };
+ const prompt = try promptOf(srv, cx, sp, input);
+- return .{ .input = input, .prompt = prompt, .seed = api.seedFor(prompt, srv.config.seed_salt), .format = format, .stream = stream };
++ return .{ .input = input, .prompt = prompt, .sampling = sampling, .format = format, .stream = stream };
+ }
+
+ /// The prompt ids of a valid UTF-8 input, refused when it cannot be spoken or is too long.
+@@ -167,11 +171,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,
+ .unconditioned = guided.rows,
+@@ -250,7 +254,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);
+@@ -540,7 +544,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 71255370..17e32fe2 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 647167e3..72339b0c 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 {
+@@ -32,6 +33,7 @@ pub const Script = struct {
+ cancelled: std.atomic.Value(bool) = .init(false),
+ thread: ?std.Thread = null,
+ seeds: [4]u64 = undefined,
++ sampling: [4]Sampling = undefined,
+ reference_rows: [4]u32 = undefined,
+ takes: usize = 0,
+ voices: []const @import("drama_voice.zig").Voice = &.{},
+@@ -61,6 +63,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/nvfp4/macos/patches/0005-sampling-json-boundaries.patch b/engine/nvfp4/macos/patches/0005-sampling-json-boundaries.patch
new file mode 100644
index 0000000..d16b0e4
--- /dev/null
+++ b/engine/nvfp4/macos/patches/0005-sampling-json-boundaries.patch
@@ -0,0 +1,90 @@
+From 2af18fbabc5a7655f36a8fc16441a4055945da84 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/nvfp4/macos/patches/SHA256SUMS b/engine/nvfp4/macos/patches/SHA256SUMS
index 22224d6..31ddc24 100644
--- a/engine/nvfp4/macos/patches/SHA256SUMS
+++ b/engine/nvfp4/macos/patches/SHA256SUMS
@@ -1,3 +1,5 @@
f282318f0e8dae43b19d1fa93ec05691696a0352eaeac41f9ffc334690d8b388 0001-drama3-metal-nvfp4-cfg.patch
0a68a3b66da1991f1a04980cb6958e4acc8856a88efdbe1fe10adc6df588cfc8 0002-shared-gate-stress-regression.patch
794a5b6008487ec705cadb098eb1c15737da743aee0855b610510506a0ce38db 0003-shared-cli-extension-point.patch
+c9c1406dc344ab359693b03584184119569ad05c686606d2f4c976eb667ce54d 0004-speech-sampling-controls.patch
+158ac17113ca3ffd04706782189a14706d4c63e343e40b81af287bae1b55cefc 0005-sampling-json-boundaries.patch
diff --git a/engine/nvfp4/patches/0002-speech-sampling-controls.patch b/engine/nvfp4/patches/0002-speech-sampling-controls.patch
new file mode 100644
index 0000000..8415b45
--- /dev/null
+++ b/engine/nvfp4/patches/0002-speech-sampling-controls.patch
@@ -0,0 +1,630 @@
+From 22939c2a0c028f56e46f909c462786a714ac921d Mon Sep 17 00:00:00 2001
+From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
+Date: Sun, 25 Oct 2026 11:59:37 +0800
+Subject: [PATCH] server: expose sampling controls in legacy CUDA speech
+
+---
+ CHANGELOG.md | 2 +
+ README.md | 3 +
+ docs/drama-voices.md | 6 +-
+ docs/speech-sampling.md | 59 +++++++++
+ zig/src/server/root.zig | 1 +
+ zig/src/server/speech.zig | 25 ++--
+ zig/src/server/speech_long.zig | 14 +-
+ zig/src/server/speech_long_test.zig | 3 +
+ zig/src/server/speech_register_test.zig | 21 +++
+ zig/src/server/speech_sampling.zig | 57 ++++++++
+ zig/src/server/speech_sampling_test.zig | 167 ++++++++++++++++++++++++
+ zig/src/server/speech_stream_test.zig | 2 +-
+ 12 files changed, 338 insertions(+), 22 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 5dd216c0..8c510990 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
+
++- speech: Drama requests accept `temperature`, `top_p`, `top_k` (1 through 64) and exact unsigned 64-bit `seed`. Whole replies, streams, references, guidance and long-form segments retain the requested settings; omitted or null fields keep existing defaults. Explicit seeds advance by each segment's attempt number on redraws. See [speech sampling](docs/speech-sampling.md).
++
+ - speech: long-form Drama 3 splits preserve complete special tokens, bracketed cues and nested paired direction tags, even when one span exceeds the character target. CJK punctuation supplies sentence and clause boundaries without requiring spaces, and topic-shift markers inside protected spans cannot split them. Existing input, prompt-token, segment and frame limits still apply ([docs](docs/drama-voices.md#long-form-replies)).
+ - speech: Drama 3's DAC activation planes are bounded by the decode chunk and causal halo rather than the utterance limit. QKV history retains absolute frame indexing and attention tile boundaries. `zig build drama-codec-chunk` builds `oracle/bin/tf-drama-codec-chunk CHECKPOINT`, a GPU byte-equality gate against the original full-plane layout; the trace diagnostic explicitly retains that reference layout.
+ - speech: `--silence-mid-s S` (CUDA; 0 for off, or 3 to below 10; default 0) ends a Drama 3 synthesis as `silence` when its end of speech comes inside S seconds or more below -40 dBFS after 1 s or more voiced: a reply that fell silent mid-way and stopped there, its last words never spoken, as one 4-bit take did after a 9.0 s hole. Holes that recover are left alone, since a client that shortens pauses never plays them, and a reply the frame cap stops is not judged. Such a draw is redrawn under the silence redraws' rules, a long-form segment in place, and whole replies count the draws it ended in `X-Silence-Mid-Trips`. Replayed at S = 3 over 2272 draws in the engine's frame accounting, it ended 15 of the 2047 that the 10 s rule left alone; all 8 with a text to judge by had lost words, and none of them had spoken in full. With S = 0 replies are as before ([docs](docs/drama-voices.md#holes-that-end-a-reply))
+diff --git a/README.md b/README.md
+index d77b9f9f..0b89e4ef 100644
+--- a/README.md
++++ b/README.md
+@@ -30,6 +30,9 @@ The API listens at `http://127.0.0.1:8080/v1` by default.
+ It serves OpenAI chat completions, completions and Responses, plus Anthropic Messages.
+ Use the model ID returned by `/v1/models`, or set one with `--name`.
+
++Drama speech at `/v1/audio/speech` accepts [request sampling controls](docs/speech-sampling.md)
++for temperature, top-p, top-k and exact 64-bit seeds, including streamed and long-form replies.
++
+ ```sh
+ curl -fsS http://127.0.0.1:8080/v1/chat/completions \
+ -H 'Content-Type: application/json' \
+diff --git a/docs/drama-voices.md b/docs/drama-voices.md
+index 2ee8d2a9..06037d30 100644
+--- a/docs/drama-voices.md
++++ b/docs/drama-voices.md
+@@ -177,7 +177,7 @@ With `--long-form`, each segment may use up to N frames, and a reply may use 600
+ tensorfold serve DRAMA3_DIR --voices voices/ --default-voice NAME --cfg-gamma 2
+ ```
+
+-On CUDA, `--cfg-gamma G` (0 for off, or 1 to 5; default 0) applies classifier-free guidance to a referenced prompt's semantic head. The main token of each frame is drawn from l_c + (G − 1)(l_c − l_u) instead of l_c: l_c are the logits of the prompt as sent (the voice's references, then the input), and l_u are those of the same turn as a plain prompt, the one the built-in voice gets. So the draw moves away from what the model says without the references. The mix covers all 4097 semantic logits, the end-of-speech logit included, so guidance also moves where a draw ends. It comes before the sampler, which is unchanged and not greedy: a draw seeded per request, at temperature 1, top-p 0.9 and top-k 30, with the repetition-aware redraw (RAS: a main token that repeats one of the last 16 frames' is replaced by a second draw at top-p 0.95). The tower, and with it codebooks 1 to 9, still reads the referenced prompt's hidden row.
++On CUDA, `--cfg-gamma G` (0 for off, or 1 to 5; default 0) applies classifier-free guidance to a referenced prompt's semantic head. The main token of each frame is drawn from l_c + (G − 1)(l_c − l_u) instead of l_c: l_c are the logits of the prompt as sent (the voice's references, then the input), and l_u are those of the same turn as a plain prompt, the one the built-in voice gets. So the draw moves away from what the model says without the references. The mix covers all 4097 semantic logits, the end-of-speech logit included, so guidance also moves where a draw ends. It comes before the native sampler, using the [request sampling controls](speech-sampling.md) (defaults: temperature 1, top-p 0.9 and top-k 30), with the repetition-aware redraw (RAS: a main token that repeats one of the last 16 frames' is replaced by a second draw at top-p 0.95). The tower, and with it codebooks 1 to 9, still reads the referenced prompt's hidden row.
+
+ - **The second sequence.** The engine keeps l_u in a second backbone sequence that shares the model's weights: 0.33 GB at context 8192 (the KV caches of the 10 attention layers and the states of the 30 DeltaNet layers). It prefills the plain prompt before the referenced one, and then reads every frame the referenced one reads. That is one more backbone run per frame, and the server's startup line gives the sequence's size.
+ - **`--cfg-every K`** (default 1) would run the second sequence only every K frames, reading the K frames in one pass, its last logits guiding the draws in between. Only K = 1 is accepted for now: tests check that a catch-up run reads every frame once and in order, but nothing has yet compared its logits with K runs one frame each, so the server refuses K > 1 at startup.
+@@ -270,7 +270,7 @@ A reply can fall silent mid-way for less than 10 s and then end, its last senten
+ tensorfold serve DRAMA3_DIR --voices voices/ --silence-redraws 2 --silence-redraw-plain
+ ```
+
+-`--silence-redraws N` (0 to 8, default 0) draws a whole reply again when it trips, up to N times: when the guard ended it, or, with `--short-reply-ratio`, when it stopped far short of its text. A redraw keeps the request's voice and seeds with the server's salt plus k for the k-th redraw, so it is a different draw that is still deterministic. Salts overlap: with the same prompt, redraw k at salt s is the same draw as the first draw at salt s + k (`TENSORFOLD_SEED_SALT`), so a sweep over salts should leave N between the salts it compares.
++`--silence-redraws N` (0 to 8, default 0) draws a whole reply again when it trips, up to N times: when the guard ended it, or, with `--short-reply-ratio`, when it stopped far short of its text. A redraw keeps the request's voice and sampling controls. Without an explicit seed it uses the server's salt plus k for the k-th redraw; an explicit seed instead advances by k modulo 2^64. See [speech sampling](speech-sampling.md). Salts overlap: with the same prompt, redraw k at salt s is the same draw as the first draw at salt s + k (`TENSORFOLD_SEED_SALT`), so a sweep over salts should leave N between the salts it compares.
+
+ - **Deadline.** Each redraw starts only if it should end within the budget: the lower of the request's `X-Deadline-S` (seconds the client waits, counted from when the speech route takes the request, before it reads the body) and `--silence-redraw-budget-s` (default 120). After a draw that tripped (ended `silence`, or a short `stop`, below) at t seconds from receipt and took d seconds, the next one is started only if t + d ≤ budget, since a stalled draw's duration predicts the next one's. Otherwise the last draw is sent as it is, with its own `X-Finish-Reason`. For example, a client that allows 96 s for a 509-character reply gets a redraw after a trip at 17 s (17 + 17 = 34 ≤ 96). After a 50 s stall it gets none (50 + 50 = 100 > 96), so it receives the tripped reply within its deadline instead of a timeout. A redraw that finds the engine busy with another request waits for it only until the latest start that still fits (budget − d after receipt), then sends the tripped draw. The deadline therefore binds every start; only a draw already generating can run past it. `--silence-redraw-budget-s` and `--silence-redraw-plain` need `--silence-redraws` of 1 or more.
+ - **Plain redraws.** With `--silence-redraw-plain`, every redraw speaks the input without its cues: each `[sighs]`, `[laughs]`, `[small laugh]`, `[pause]` and `[hesitates]` becomes a space, then white space is collapsed to single spaces and trimmed (`done. [pause] Next` is spoken `done. Next`; `word [sighs].` is spoken `word .`). An input without one of those cues is redrawn as it is. A plain redraw never speaks the cues again: if nothing is left without them, or the cue-free text cannot be prompted, there is no redraw, the tripped draw is sent, and the log says why. The cues drove the trips measured: on texts that tripped the guard, a cue-free draw finished 14 of 14, a draw with the cues and a new seed 5 of 14.
+@@ -321,7 +321,7 @@ With `--long-form`, a speech input longer than `--long-form-chars` (default 500
+ - a cut inside a sentence (at `,` `;` `:` `,` `:` `、` or a space): 0.20 s
+ - **Room tone.** The added pause is copied from the earlier segment's quietest 100 ms in its last second, played back and forth. It is digital silence when that segment has no quiet tail.
+ - **Level.** A segment whose speech level (the median dBFS of its frames at or above -42 dBFS) is more than 1.5 dB from the median of the earlier segments gets one constant gain. The gain brings it to 1.5 dB, is at most 3 dB either way, and never raises a peak past 0.99.
+-- **Seeds.** Each segment's seed comes from its own prompt rows, as a single synthesis's does.
++- **Seeds.** Without an explicit request seed, each segment's seed comes from its own prompt rows, as a single synthesis's does. An explicit seed starts each segment's attempt count at zero; its redraws use seed + attempt modulo 2^64. Every segment keeps the same requested temperature, top-p and top-k; see [speech sampling](speech-sampling.md).
+ - **Limits.**
+ - More than 12 segments are refused up front.
+ - A reply may use 6000 frames in all; each segment gets at most what is left.
+diff --git a/docs/speech-sampling.md b/docs/speech-sampling.md
+new file mode 100644
+index 00000000..10f366ac
+--- /dev/null
++++ b/docs/speech-sampling.md
+@@ -0,0 +1,59 @@
++# 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.
++
++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' \
++ -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 existing native sampler, including its
++repetition aware sampling (RAS) behavior. Matching fields and seeds do not
++promise identical audio across backends or checkpoints. The HTTP limit of 64
++candidates is shared with the Metal recipe.
++
++An explicit `seed` is used exactly for the first take. If configured
++[speech redraws](drama-voices.md#silence-redraws) 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.
++
++For a long-form reply, each segment starts its own attempt count at zero: its
++first take uses the explicit seed, and its redraws use seed + attempt modulo
++2^64. Sampling values stay the same across segments, reference carry-over,
++control stripping and guidance. Omitted seeds retain each segment's existing
++prompt hash and server salt plus that segment's attempt number.
++
++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 5e62886e..3a753858 100644
+--- a/zig/src/server/root.zig
++++ b/zig/src/server/root.zig
+@@ -56,6 +56,7 @@ test {
+ _ = @import("drama_cfg.zig");
+ _ = @import("speech_cfg_test.zig");
+ _ = @import("speech_long_test.zig");
++ _ = @import("speech_sampling_test.zig");
+ _ = @import("speech_redraw.zig");
+ _ = @import("speech_redraw_test.zig");
+ _ = @import("speech_redraw_off_test.zig");
+diff --git a/zig/src/server/speech.zig b/zig/src/server/speech.zig
+index 48ad9baf..73e55aff 100644
+--- a/zig/src/server/speech.zig
++++ b/zig/src/server/speech.zig
+@@ -10,6 +10,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 drama_voice = @import("drama_voice.zig");
+ const drama_shift = @import("drama_shift.zig");
+ const drama_allow = @import("drama_allow.zig");
+@@ -35,7 +36,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 audio references this route does not honor.
+-const modifier_fields = [_][]const u8{ "speed", "task_type", "language", "instructions", "ref_audio", "ref_text", "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", "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,
+@@ -50,7 +51,7 @@ pub const Format = enum {
+ };
+
+ /// A synthesis's prompt and sources, or a long-form reply; `dropped`: `¶` marks not honoured; `stripped`: controls cut.
+-const Plan = struct { prompt: []const u32, codes: []const drama_prompt.Codes, input: []const u8, references: []const drama_prompt.Reference, format: Format, stream: bool, long: ?speech_long.Long = null, dropped: usize = 0, stripped: ?usize = null };
++const Plan = struct { prompt: []const u32, codes: []const drama_prompt.Codes, input: []const u8, references: []const drama_prompt.Reference, sampling: speech_sampling.Options, format: Format, stream: bool, long: ?speech_long.Long = null, dropped: usize = 0, stripped: ?usize = null };
+
+ pub fn post(srv: *Server, c: *Conn, a: Allocator) !void {
+ const sp = srv.info.speech orelse {
+@@ -94,8 +95,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 (std.mem.eql(u8, key, "references")) {
+- // lowered with the voice, after the input's checks (drama_voice.lower)
++ } else if (std.mem.eql(u8, key, "references") or speech_sampling.isField(key)) {
++ // references and sampling are checked in prepare before submission
+ } 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});
+@@ -114,6 +115,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 });
+@@ -135,15 +137,15 @@ fn prepare(srv: *Server, cx: *Cx, sp: api.SpeechInfo, raw: Value) errors.Refused
+ const voice = raw.strField("voice") orelse raw.strField("speaker") orelse "default";
+ const references = try drama_voice.lower(cx, input, raw.get("references") orelse .null, try voiceReferences(srv, cx, voice));
+ if (references.len == 0) if (try drama_prompt.problem(cx.a, input)) |message| return cx.refuse(message);
+- if (srv.config.long_form) |options| if (try speech_long.plan(srv, cx, sp, marked, references, options)) |long|
+- return .{ .prompt = &.{}, .codes = &.{}, .input = input, .references = references, .format = format, .stream = stream, .long = long, .dropped = marked.dropped, .stripped = stripped };
++ if (srv.config.long_form) |options| if (try speech_long.plan(srv, cx, sp, marked, references, options, sampling)) |long|
++ return .{ .prompt = &.{}, .codes = &.{}, .input = input, .references = references, .sampling = sampling, .format = format, .stream = stream, .long = long, .dropped = marked.dropped, .stripped = stripped };
+ const rows = drama_prompt.promptRows(cx.a, srv.text, input, references) catch |e| return switch (e) {
+ error.OutOfMemory => error.OutOfMemory,
+ error.Template => cx.other("the tokenizer cannot encode this prompt"),
+ };
+ if (rows.tokens.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", .{ rows.tokens.len, sp.max_prompt_tokens });
+- return .{ .prompt = rows.tokens, .codes = rows.codes, .input = input, .references = references, .format = format, .stream = stream, .dropped = marked.dropped, .stripped = stripped };
++ return .{ .prompt = rows.tokens, .codes = rows.codes, .input = input, .references = references, .sampling = sampling, .format = format, .stream = stream, .dropped = marked.dropped, .stripped = stripped };
+ }
+
+ /// What ``voice`` names: the built-in voice (none), --default-voice's for ``default``, else a --voices file's.
+@@ -184,11 +186,11 @@ fn synthesize(srv: *Server, c: *Conn, cx: *Cx, id: []const u8, sp: api.SpeechInf
+ .codes = rows.codes,
+ .pcm = audio,
+ .result = &result,
+- .seed = api.seedFor(rows.tokens, speech_redraw.Redraws.salt(srv.config.seed_salt, k)),
++ .seed = plan.sampling.seedFor(rows.tokens, srv.config.seed_salt, k),
+ .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,
+ .unconditioned = guided.unconditioned,
+ .cfg_gamma = guided.gamma,
+ .stream = plan.stream,
+@@ -573,7 +575,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_long.zig b/zig/src/server/speech_long.zig
+index 619e0f79..a19a5fdc 100644
+--- a/zig/src/server/speech_long.zig
++++ b/zig/src/server/speech_long.zig
+@@ -4,6 +4,7 @@ const api = @import("engine_api");
+ const errors = @import("errors.zig");
+ const speech = @import("speech.zig");
+ const speech_redraw = @import("speech_redraw.zig");
++const speech_sampling = @import("speech_sampling.zig");
+ const drama_prompt = @import("drama_prompt.zig");
+ const drama_long = @import("drama_long.zig");
+ const drama_joins = @import("drama_joins.zig");
+@@ -26,6 +27,7 @@ pub const Long = struct {
+ references: []const Reference,
+ history: u8,
+ shift_pause_s: f32,
++ sampling: speech_sampling.Options,
+
+ fn shifted(l: Long, i: usize) bool {
+ return i < l.shifts.len and l.shifts[i];
+@@ -37,7 +39,7 @@ const trailer = "X-Finish-Reason, X-Long-Form-Segments";
+ const shift_trailer = trailer ++ ", X-Pause-Spans";
+
+ /// The input's segments, cut at each of `marked`'s cuts, each refused now as a whole input would be; null: one reply.
+-pub fn plan(srv: *Server, cx: *Cx, sp: api.SpeechInfo, marked: drama_shift.Marked, references: []const Reference, options: drama_long.Options) errors.Refused!?Long {
++pub fn plan(srv: *Server, cx: *Cx, sp: api.SpeechInfo, marked: drama_shift.Marked, references: []const Reference, options: drama_long.Options, sampling: speech_sampling.Options) errors.Refused!?Long {
+ const input = marked.text;
+ if (marked.cuts.len == 0 and (std.unicode.utf8CountCodepoints(input) catch unreachable) <= options.chars) return null;
+ const split = try drama_shift.split(cx.a, marked, options.chars);
+@@ -54,7 +56,7 @@ pub fn plan(srv: *Server, cx: *Cx, sp: api.SpeechInfo, marked: drama_shift.Marke
+ if (rows.tokens.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", .{ rows.tokens.len, sp.max_prompt_tokens });
+ }
+- return .{ .input = input, .segments = segments, .shifts = split.shifts, .references = references, .history = options.history, .shift_pause_s = options.shift_pause_s };
++ return .{ .input = input, .segments = segments, .shifts = split.shifts, .references = references, .history = options.history, .shift_pause_s = options.shift_pause_s, .sampling = sampling };
+ }
+
+ /// Speaks every segment into one reply: a WAV or PCM body, or a PCM stream from the first segment's first audio.
+@@ -156,11 +158,11 @@ const Reply = struct {
+ .generated = r.generated[r.frames..][0..room],
+ .pcm = r.scratch[0 .. room * fl],
+ .result = &result,
+- .seed = api.seedFor(rows.tokens, speech_redraw.Redraws.salt(r.srv.config.seed_salt, k)),
++ .seed = r.long.sampling.seedFor(rows.tokens, r.srv.config.seed_salt, k),
+ .max_frames = @intCast(room),
+- .temperature = drama_prompt.temperature,
+- .top_p = drama_prompt.top_p,
+- .top_k = drama_prompt.top_k,
++ .temperature = r.long.sampling.temperature,
++ .top_p = r.long.sampling.top_p,
++ .top_k = r.long.sampling.top_k,
+ .unconditioned = guided.unconditioned,
+ .cfg_gamma = guided.gamma,
+ .stream = live,
+diff --git a/zig/src/server/speech_long_test.zig b/zig/src/server/speech_long_test.zig
+index 8fb9a7ba..de6e3fef 100644
+--- a/zig/src/server/speech_long_test.zig
++++ b/zig/src/server/speech_long_test.zig
+@@ -8,6 +8,7 @@ const harness = @import("speech_stream_test.zig");
+ const Served = harness.Served;
+
+ const gpa = std.testing.allocator;
++pub const Sampling = struct { temperature: f32, top_p: f32, top_k: u32 };
+ /// 1 kHz audio in 10-sample frames: a codec frame is one 10 ms join frame, and a sentence's pause is 420 samples.
+ const rate = 1000;
+ const frame = 10;
+@@ -26,6 +27,7 @@ pub const Turns = struct {
+ prompts: std.ArrayList([]const u32) = .empty,
+ codes: std.ArrayList([]const drama_prompt.Codes) = .empty,
+ seeds: std.ArrayList(u64) = .empty,
++ sampling: std.ArrayList(Sampling) = .empty,
+ limits: std.ArrayList(u32) = .empty,
+ generated: std.ArrayList(usize) = .empty,
+ streamed: std.ArrayList(bool) = .empty,
+@@ -54,6 +56,7 @@ pub const Turns = struct {
+ t.prompts.append(a, a.dupe(u32, request.prompt) catch unreachable) catch unreachable;
+ t.codes.append(a, a.dupe(drama_prompt.Codes, request.codes) catch unreachable) catch unreachable;
+ t.seeds.append(a, request.seed) catch unreachable;
++ t.sampling.append(a, .{ .temperature = request.temperature, .top_p = request.top_p, .top_k = request.top_k }) catch unreachable;
+ t.limits.append(a, request.max_frames) catch unreachable;
+ t.generated.append(a, request.generated.len) catch unreachable;
+ t.streamed.append(a, request.stream) catch unreachable;
+diff --git a/zig/src/server/speech_register_test.zig b/zig/src/server/speech_register_test.zig
+index 4e9d24bb..031a0113 100644
+--- a/zig/src/server/speech_register_test.zig
++++ b/zig/src/server/speech_register_test.zig
+@@ -26,6 +26,7 @@ const Draws = struct {
+ refused: u8 = 0,
+ prompts: [4][]const u32 = undefined,
+ seeds: [4]u64 = undefined,
++ sampling: [4]scripted.Sampling = undefined,
+
+ fn engine(d: *Draws) api.Engine {
+ return .{ .ctx = d, .vtable = &.{ .info = info, .submit = submit, .cancel = cancel, .status = status, .memory = memory, .submit_speech = speech } };
+@@ -45,6 +46,7 @@ const Draws = struct {
+ d.refused = 0;
+ d.prompts[d.seen] = d.a.dupe(u32, request.prompt) catch unreachable;
+ d.seeds[d.seen] = request.seed;
++ d.sampling[d.seen] = .{ .temperature = request.temperature, .top_p = request.top_p, .top_k = request.top_k };
+ d.seen += 1;
+ @memcpy(request.pcm[0..draw.pcm.len], draw.pcm);
+ if (request.stream) sink.event(sink.ctx, id, &.{ .audio = draw.pcm.len });
+@@ -117,6 +119,25 @@ test "a jump trips one redraw at the next salt; the calmer redraw is sent, marke
+ try std.testing.expectEqualSlices(u32, d.prompts[0], d.prompts[1]);
+ }
+
++test "speech sampling: silence then register redraws retain settings and increment the exact seed" {
++ var arena: std.heap.ArenaAllocator = .init(gpa);
++ defer arena.deinit();
++ const a = arena.allocator();
++ const calm = try reply(a, 178);
++ var d: Draws = .{ .a = a, .draws = &.{ .{ .pcm = calm, .reason = .silence }, .{ .pcm = try reply(a, 240) }, .{ .pcm = calm } } };
++ const request = "{\"model\":\"m\",\"input\":\"One. Two.\",\"response_format\":\"pcm\",\"temperature\":0.5,\"top_p\":0.75,\"top_k\":8,\"seed\":9007199254740993}";
++ const r = try serve(&d, config(on, .{ .max = 1 }), null, request);
++ defer scripted.done(r.s);
++ try harness.expectHeader(r.head, "HTTP/1.1 200");
++ try harness.expectHeader(r.head, "X-Silence-Redraws: 1");
++ try harness.expectHeader(r.head, "X-Register-Redraws: 1");
++ try std.testing.expectEqual(@as(usize, 3), d.seen);
++ for (0..d.seen) |k| {
++ try std.testing.expectEqual(@as(u64, 9007199254740993) + k, d.seeds[k]);
++ try std.testing.expectEqualDeep(scripted.Sampling{ .temperature = 0.5, .top_p = 0.75, .top_k = 8 }, d.sampling[k]);
++ }
++}
++
+ test "an untripped reply is drawn once and sent byte for byte as with the guard off, marked Trip 0 and Redraws 0" {
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ 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..46c6afff
+--- /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.zig").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 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);
++ 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..7fc6d406
+--- /dev/null
++++ b/zig/src/server/speech_sampling_test.zig
+@@ -0,0 +1,167 @@
++//! Speech sampling controls through the HTTP route, including long-form retries and carried reference prompts.
++const std = @import("std");
++const api = @import("engine_api");
++const server = @import("root.zig");
++const harness = @import("speech_stream_test.zig");
++const scripted = @import("speech_long_test.zig");
++const drama_prompt = @import("drama_prompt.zig");
++const gpa = std.testing.allocator;
++const config: server.Config = .{ .served_name = "m", .model_ids = &.{"m"}, .seed_salt = 17 };
++const defaults: scripted.Sampling = .{ .temperature = 1, .top_p = 0.9, .top_k = 30 };
++
++fn exchange(turns: *scripted.Turns, cfg: server.Config, body: []const u8) ![]u8 {
++ var s: harness.Served = undefined;
++ try s.startWith(turns.engine(), cfg, "HTTP/1.1", body, 2000);
++ defer s.deinit();
++ defer s.route.join();
++ _ = try s.readHead();
++ _ = try s.readRest();
++ return gpa.dupe(u8, s.got.items);
++}
++
++fn expectDraw(turns: *const scripted.Turns, index: usize, sampling: scripted.Sampling, seed: u64) !void {
++ try std.testing.expectEqualDeep(sampling, turns.sampling.items[index]);
++ try std.testing.expectEqual(seed, turns.seeds.items[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 turns: scripted.Turns = .{ .turns = &.{.{ .frames = 5 }} };
++ defer turns.deinit();
++ const reply = try exchange(&turns, config, body);
++ defer gpa.free(reply);
++ try harness.expectHeader(reply, "HTTP/1.1 200");
++ try std.testing.expectEqual(@as(usize, 1), turns.seen);
++ try expectDraw(&turns, 0, defaults, api.seedFor(turns.prompts.items[0], config.seed_salt));
++ 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: scripted.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 = null },
++ .{ .fields = "\"temperature\":1e-400", .sampling = .{ .temperature = 0, .top_p = 0.9, .top_k = 30 }, .seed = null },
++ .{ .fields = "\"temperature\":-1e-400", .sampling = .{ .temperature = -0.0, .top_p = 0.9, .top_k = 30 }, .seed = null },
++ .{ .fields = "\"temperature\":3.4028234663852886e38,\"top_p\":1e-45", .sampling = .{ .temperature = std.math.floatMax(f32), .top_p = 1e-45, .top_k = 30 }, .seed = null },
++ };
++ 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 turns: scripted.Turns = .{ .turns = &.{.{ .frames = 5 }} };
++ defer turns.deinit();
++ const reply = try exchange(&turns, 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), turns.seen);
++ try expectDraw(&turns, 0, case.sampling, case.seed orelse api.seedFor(turns.prompts.items[0], config.seed_salt));
++ };
++}
++
++test "speech sampling: invalid controls fail before any engine submission" {
++ const fields = [_][]const u8{
++ "\"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});
++ defer gpa.free(body);
++ var turns: scripted.Turns = .{ .turns = &.{.{ .frames = 5 }} };
++ defer turns.deinit();
++ const reply = try exchange(&turns, config, body);
++ defer gpa.free(reply);
++ try harness.expectHeader(reply, "HTTP/1.1 400");
++ try std.testing.expectEqual(@as(usize, 0), turns.seen);
++ }
++}
++
++test "speech sampling: referenced re-prompts keep overrides and wrap explicit retry seeds" {
++ const frames = [_]drama_prompt.Codes{@splat(7)};
++ const refs = [_]drama_prompt.Reference{.{ .text = "A small cloud passes.", .frames = &frames }};
++ const voices = [_]@import("drama_voice.zig").Voice{.{ .name = "tone", .references = &refs, .source = .@"model-invented" }};
++ var cfg = config;
++ cfg.voices = &voices;
++ cfg.cfg = .{ .gamma = 2 };
++ cfg.silence_redraw = .{ .max = 2, .plain = true };
++ var turns: scripted.Turns = .{ .turns = &.{ .{ .frames = 5, .reason = .silence }, .{ .frames = 5, .reason = .silence }, .{ .frames = 5 } } };
++ defer turns.deinit();
++ const reply = try exchange(&turns, cfg, "{\"model\":\"m\",\"input\":\"hi [sighs] [laughs] there\",\"voice\":\"tone\",\"temperature\":0.5,\"top_p\":0.75,\"top_k\":8,\"seed\":18446744073709551615}");
++ defer gpa.free(reply);
++ try harness.expectHeader(reply, "HTTP/1.1 200");
++ try std.testing.expectEqual(@as(usize, 3), turns.seen);
++ for ([_]u64{ std.math.maxInt(u64), 0, 1 }, 0..) |seed, k| {
++ try expectDraw(&turns, k, .{ .temperature = 0.5, .top_p = 0.75, .top_k = 8 }, seed);
++ var reference_frames: usize = 0;
++ for (turns.codes.items[k]) |codes| if (std.meta.eql(frames[0], codes)) {
++ reference_frames += 1;
++ };
++ try std.testing.expectEqual(@as(usize, 1), reference_frames);
++ try std.testing.expectEqual(@as(f32, 2), turns.gammas.items[k]);
++ try std.testing.expect(turns.unconditioned.items[k].len > 0);
++ }
++ try std.testing.expect(!std.mem.eql(u32, turns.prompts.items[0], turns.prompts.items[1]));
++}
++
++test "speech sampling: long-form segment retries preserve overrides, carried prompts and per-segment seeds" {
++ var cfg = config;
++ cfg.long_form = .{ .chars = 20 };
++ cfg.silence_redraw = .{ .max = 1, .plain = true };
++ cfg.cfg = .{ .gamma = 2 };
++ for ([_]bool{ false, true }) |explicit| {
++ const seed_field = if (explicit) ",\"seed\":18446744073709551615" else "";
++ const body = try std.fmt.allocPrint(gpa, "{{\"model\":\"m\",\"input\":\"Sentence 00 is ok. Sentence [sighs] ok. Sentence 02 is ok.\",\"temperature\":0.5,\"top_p\":0.75,\"top_k\":8{s}}}", .{seed_field});
++ defer gpa.free(body);
++ var turns: scripted.Turns = .{ .turns = &.{ .{ .frames = 5 }, .{ .frames = 5, .reason = .silence }, .{ .frames = 5, .busy = 1 }, .{ .frames = 5 } } };
++ defer turns.deinit();
++ const reply = try exchange(&turns, cfg, body);
++ defer gpa.free(reply);
++ try harness.expectHeader(reply, "HTTP/1.1 200");
++ try std.testing.expectEqual(@as(usize, 4), turns.seen);
++ for ([_]usize{ 0, 0, 1, 0 }, 0..) |attempt, k| {
++ const seed = if (explicit) std.math.maxInt(u64) +% @as(u64, @intCast(attempt)) else api.seedFor(turns.prompts.items[k], config.seed_salt + @as(i64, @intCast(attempt)));
++ try expectDraw(&turns, k, .{ .temperature = 0.5, .top_p = 0.75, .top_k = 8 }, seed);
++ try std.testing.expectEqual(@as(f32, if (k == 0) 1 else 2), turns.gammas.items[k]);
++ }
++ try std.testing.expect(!std.mem.eql(u32, turns.prompts.items[1], turns.prompts.items[2]));
++ try std.testing.expect(turns.codes.items[3].len > 0);
++ }
++}
++
++test "speech sampling: a silenced long-form stream keeps controls without redrawing" {
++ var cfg = config;
++ cfg.long_form = .{ .chars = 20 };
++ cfg.silence_redraw = .{ .max = 2 };
++ var turns: scripted.Turns = .{ .turns = &.{ .{ .frames = 5 }, .{ .frames = 5, .reason = .silence } } };
++ defer turns.deinit();
++ const reply = try exchange(&turns, cfg, "{\"model\":\"m\",\"input\":\"Sentence 00 is ok. Sentence 01 is ok. Sentence 02 is ok.\",\"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, 2), turns.seen);
++ for (0..2) |k| try expectDraw(&turns, k, .{ .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 5cdcad00..8781d280 100644
+--- a/zig/src/server/speech_stream_test.zig
++++ b/zig/src/server/speech_stream_test.zig
+@@ -236,7 +236,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);
+ }
+
+--
+2.46.0
+
diff --git a/engine/nvfp4/patches/README.md b/engine/nvfp4/patches/README.md
index 70cc1e1..5242c7c 100644
--- a/engine/nvfp4/patches/README.md
+++ b/engine/nvfp4/patches/README.md
@@ -7,6 +7,11 @@ selected patch exactly once, verifies its digest, then applies the series.
| Patch | Scope | Source PR |
|---|---|---|
| `0001-protected-speech-chunks.patch` | Protected markup and CJK long-form boundaries; test fixture drain ordering | [TensorFold #5](https://github.com/plotarmordev/TensorFold/pull/5) |
+| `0002-speech-sampling-controls.patch` | Request sampling through whole/streamed, reference, CFG and long-form redraw paths | [TensorFold #6](https://github.com/plotarmordev/TensorFold/pull/6) |
+
+Patch 0002 adds [request sampling](../../../recipe/README-sampling.md). Each
+long-form segment starts with the supplied seed; its retries add their own
+attempt number. Omitted seeds retain the existing per-segment prompt hash.
Protected constructs are complete model special tokens, balanced bracketed
spans and correctly nested, exact-name ASCII tags without attributes. Unclosed
diff --git a/engine/nvfp4/patches/SHA256SUMS b/engine/nvfp4/patches/SHA256SUMS
index ec0229f..c27c658 100644
--- a/engine/nvfp4/patches/SHA256SUMS
+++ b/engine/nvfp4/patches/SHA256SUMS
@@ -1 +1,2 @@
c0553b2cfd231ddbbe6390edfe49546db5bef084d5bb6a5009542ccccfdd2fb9 0001-protected-speech-chunks.patch
+34e0110f8dad8382a8b592aa7d2e63fc9c2e472b6f3528cf555f770eb5bfeb25 0002-speech-sampling-controls.patch
diff --git a/engine/patches/0013-speech-sampling-controls.patch b/engine/patches/0013-speech-sampling-controls.patch
new file mode 100644
index 0000000..fc0c1ce
--- /dev/null
+++ b/engine/patches/0013-speech-sampling-controls.patch
@@ -0,0 +1,536 @@
+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
new file mode 100644
index 0000000..8af3d25
--- /dev/null
+++ b/engine/patches/0014-sampling-json-boundaries.patch
@@ -0,0 +1,90 @@
+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 6a1f7bb..fb717c8 100644
--- a/engine/patches/README.md
+++ b/engine/patches/README.md
@@ -14,6 +14,10 @@ engine loads are under the Fish Audio Community License v1.0 and are downloaded
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.
@@ -32,6 +36,8 @@ It does not change the full-model series above or the CUDA NVFP4 pin.
| [`0001-drama3-metal-nvfp4-cfg.patch`](../nvfp4/macos/patches/0001-drama3-metal-nvfp4-cfg.patch) | Packed NVFP4 experts, compact routing, native reference CFG and registered numerical contracts | [TensorFold #1268](https://github.com/ashhart/TensorFold/pull/1268) |
| [`0002-shared-gate-stress-regression.patch`](../nvfp4/macos/patches/0002-shared-gate-stress-regression.patch) | Test-only correction that distinguishes reduced-precision accumulation after sigmoid; no kernel or tolerance change | [TensorFold #1268](https://github.com/ashhart/TensorFold/pull/1268) |
| [`0003-shared-cli-extension-point.patch`](../nvfp4/macos/patches/0003-shared-cli-extension-point.patch) | Group CFG flags beside voice selection without occupying shared CLI-table extension points; no parsing or numerical change | [TensorFold #1268](https://github.com/ashhart/TensorFold/pull/1268) |
+| [`0004-speech-sampling-controls.patch`](../nvfp4/macos/patches/0004-speech-sampling-controls.patch) | Request sampling parameters and exact seeds, retaining CFG rows | [TensorFold #1277](https://github.com/ashhart/TensorFold/pull/1277) |
+| [`0005-sampling-json-boundaries.patch`](../nvfp4/macos/patches/0005-sampling-json-boundaries.patch) | Parsed-JSON floating boundary documentation and regressions | [TensorFold #1277](https://github.com/ashhart/TensorFold/pull/1277) |
The [Mac qualification receipt](../../results/macos-nvfp4.json) separates observed GPU/model results,
the initial failed fixture and focused correction, and the final CPU-only CLI integration build.
diff --git a/engine/patches/SHA256SUMS b/engine/patches/SHA256SUMS
index 40e7b54..fe92a6c 100644
--- a/engine/patches/SHA256SUMS
+++ b/engine/patches/SHA256SUMS
@@ -10,3 +10,5 @@ a1289601a1aeccd57354320f55d6563108d40a5a048596760f8498b0cc375773 0008-feat-spee
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-sampling.md b/recipe/README-sampling.md
new file mode 100644
index 0000000..6ccb238
--- /dev/null
+++ b/recipe/README-sampling.md
@@ -0,0 +1,57 @@
+# Per-request speech sampling
+
+All three pinned engine profiles accept `temperature`, `top_p`, `top_k` and `seed`
+on `POST /v1/audio/speech`, for whole and streamed responses. For example:
+
+```json
+{
+ "model": "fishaudio/drama-3",
+ "input": "The train leaves at seven fifteen.",
+ "response_format": "pcm",
+ "temperature": 0.8,
+ "top_p": 0.9,
+ "top_k": 30,
+ "seed": 42
+}
+```
+
+| Field | Accepted values | Omitted or `null` |
+|---|---|---|
+| `temperature` | Finite nonnegative number representable by the native `f32` sampler | `1.0` |
+| `top_p` | Finite number in `(0, 1]` that remains positive as `f32` | `0.9` |
+| `top_k` | JSON integer from 1 through 64 | `30` |
+| `seed` | JSON integer from 0 through 18446744073709551615 | Existing prompt-derived seed |
+
+Booleans, numeric strings, fractional integer fields and out-of-range values are
+refused before submitting to the engine. Seeds are parsed as integers without a
+floating-point conversion. Clients that cannot represent all 64-bit integers
+exactly should use a smaller seed, such as the example above.
+
+Floating fields are validated after the existing JSON parser converts them to
+`f64`. Extremely small decimals such as `1e-400` and `-1e-400` become zero there:
+both request temperature 0, while both are refused for `top_p`. A positive
+parsed value such as `1e-100` is refused when narrowing to `f32` would make it zero.
+
+An explicit seed is used unchanged for the first take. Redraws add the attempt
+number with unsigned 64-bit wraparound. The selected sampling parameters survive
+reference-prompt rebuilding and silence/register redraws. Without an explicit
+seed, prompt-derived seeding retains its existing behavior.
+
+Legacy CUDA long-form requests apply that policy separately to each segment:
+each segment starts with the supplied seed, and retries within that segment add
+their attempt number. Omitted seeds continue to hash each segment's own prompt.
+
+The HTTP limit of 64 for `top_k` is shared across the CUDA and Metal profiles.
+Sampling still uses each engine's native algorithm, including its existing
+repetition-aware behavior. Equal settings do not promise equal audio across
+backends, checkpoints or engine versions. The full-model and Metal NVFP4 profiles
+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.
+
+CPU regressions exercise validation and the values actually submitted through
+the HTTP route. Device/model determinism and audio-quality qualification are
+separate checks; this change makes no performance or quality claim.
From 0b2e000b52adb8344f1e922c175680753dc31747 Mon Sep 17 00:00:00 2001
From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
Date: Sun, 25 Oct 2026 12:12:45 +0800
Subject: [PATCH 5/7] Preserve dialogue speaker roles across all pinned Drama
engines
---
CHANGELOG.md | 4 +
README.md | 5 +
.../patches/0006-dialogue-speakers.patch | 358 ++++++++++
engine/nvfp4/macos/patches/SHA256SUMS | 1 +
.../patches/0003-dialogue-speakers.patch | 642 ++++++++++++++++++
engine/nvfp4/patches/README.md | 11 +-
engine/nvfp4/patches/SHA256SUMS | 1 +
engine/patches/0015-dialogue-speakers.patch | 341 ++++++++++
engine/patches/README.md | 6 +
engine/patches/SHA256SUMS | 1 +
recipe/README-dialogue.md | 42 ++
recipe/build_voice.py | 4 +-
recipe/test_build_voice.py | 21 +-
13 files changed, 1433 insertions(+), 4 deletions(-)
create mode 100644 engine/nvfp4/macos/patches/0006-dialogue-speakers.patch
create mode 100644 engine/nvfp4/patches/0003-dialogue-speakers.patch
create mode 100644 engine/patches/0015-dialogue-speakers.patch
create mode 100644 recipe/README-dialogue.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 820b66e..bdf3b0c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,10 @@
### Added
+- Canonical speaker tags 0..15 in HTTP speech requests and reference transcripts,
+ including native/Python catalog writers. Legacy CUDA long-form continuations
+ retain the active speaker across splits and redraws; reference-count and malformed-tag
+ validation remain enforced. No voice-quality claim is made.
- Per-request HTTP speech `temperature`, `top_p`, `top_k` and exact unsigned 64-bit
`seed` on all pinned profiles, with strict validation and propagation through
streaming, references and redraws. Omitted/null values retain existing defaults.
diff --git a/README.md b/README.md
index 3440f74..e320cb7 100644
--- a/README.md
+++ b/README.md
@@ -131,6 +131,10 @@ as codec codes (not audio). The Mac NVFP4 engine instead uses attested `drama3-h
catalogs and refuses request-supplied references; see the [Mac voice commands](recipe/README-macos.md#attested-reference-voices).
Guidance-enabled replies carry the configured γ in `X-Cfg-Gamma`; builtin voices remain unguided.
+Request text and reference transcripts accept canonical speaker tags 0 through 15.
+See [speaker-tagged dialogue](recipe/README-dialogue.md) for catalog examples,
+profile-specific reference limits and legacy long-form speaker continuation.
+
- **Whole reply:** `Content-Length` framing and an `X-Finish-Reason` header: `stop`, `length` or, with the silence
guard on, `silence`.
- **Streamed reply:** chunked mono 16-bit 44.1 kHz PCM. A reply that ends in `length` or `silence` ends with an
@@ -179,6 +183,7 @@ launch flag. Each patch's notes are in the engine tree's `CHANGELOG.md` or `docs
| 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) |
+| 0015 | Canonical speaker tags 0..15 in speech and catalog transcripts | speaker tags in text | [#1278](https://github.com/ashhart/TensorFold/pull/1278) |
`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.
diff --git a/engine/nvfp4/macos/patches/0006-dialogue-speakers.patch b/engine/nvfp4/macos/patches/0006-dialogue-speakers.patch
new file mode 100644
index 0000000..02e163b
--- /dev/null
+++ b/engine/nvfp4/macos/patches/0006-dialogue-speakers.patch
@@ -0,0 +1,358 @@
+From a6de32a3021645fdcfea156c3c4c9ee9680b185e Mon Sep 17 00:00:00 2001
+From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
+Date: Sun, 25 Oct 2026 11:47:33 +0800
+Subject: [PATCH] server: accept dialogue speaker tags zero through fifteen
+
+---
+ CHANGELOG.md | 2 +
+ README.md | 2 +-
+ docs/drama-http-voices.md | 22 +++-
+ zig/src/families/drama3/prompt.zig | 29 ++++++
+ zig/src/server/drama_prompt.zig | 40 ++-----
+ zig/src/server/drama_voice_tool.zig | 2 +-
+ zig/src/server/root.zig | 1 +
+ zig/src/server/speech_speakers_test.zig | 132 ++++++++++++++++++++++++
+ 8 files changed, 197 insertions(+), 33 deletions(-)
+ create mode 100644 zig/src/server/speech_speakers_test.zig
+
+diff --git a/CHANGELOG.md b/CHANGELOG.md
+index 4f62fc5d..2169f747 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 accepts dialogue tags `<|speaker:0|>` through `<|speaker:15|>` in whole and streamed HTTP requests and catalog reference transcripts. Untagged text still uses speaker 0, and catalogs still allow at most four reference passages. See [dialogue speakers](docs/drama-http-voices.md#dialogue-speakers).
++
+ - 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).
+
+ - Drama 3 on Metal reads the pruned NVFP4 checkpoint's packed E2M1 experts, block-16 E4M3 scales and F32 global scales without requantizing or expanding the experts. Routing masks dropped original IDs before softmax and maps selected IDs to compact slots. The full FP8-expert path retains its existing arithmetic.
+diff --git a/README.md b/README.md
+index 92896199..2b354d09 100644
+--- a/README.md
++++ b/README.md
+@@ -178,7 +178,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` with [sampling controls](docs/speech-sampling.md); 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), [reference voices and dialogue speakers](docs/drama-http-voices.md). `/v1/audio/speech` with [sampling controls](docs/speech-sampling.md); checkpoint and `codec.pth` required. |
+ | Drama 3 pruned NVFP4, speech | `hf download plotarmordev/drama-3-NVFP4-Pruned --revision 494db08481db952f834b2241bce63fb3dfa72554 --local-dir drama-3-NVFP4-Pruned` | Native Metal; roughly 21 GiB checkpoint plus runtime state. Not a minimum-RAM or cross-backend quality claim. | Packed experts, BF16 dense/tower weights and FP32 accumulation; no conversion. Use `serve drama-3-NVFP4-Pruned --cfg-gamma 1.5 --cfg-batched` with an attested catalog voice. |
+
+ ### GLM-5.3: one machine or two?
+diff --git a/docs/drama-http-voices.md b/docs/drama-http-voices.md
+index 98480bff..17013be6 100644
+--- a/docs/drama-http-voices.md
++++ b/docs/drama-http-voices.md
+@@ -22,7 +22,27 @@ The model's licence, the [Fish Audio Community License Agreement](https://huggin
+
+ 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. 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.
++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, malformed 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.
++
++## Dialogue speakers
++
++Speech input and reference transcripts accept `<|speaker:N|>` with decimal IDs from `0` through `15`. Tags can change speakers within one request. Untagged text defaults to speaker 0; a leading tag after Unicode whitespace is preserved. Leading zeros, negative IDs, non-ASCII digits, incomplete tags and IDs above 15 are rejected.
++
++For example, send this body to `POST /v1/audio/speech` with your served model name:
++
++```json
++{
++ "model": "MODEL",
++ "input": "<|speaker:0|> Is the gate open? <|speaker:15|> Yes, come through. <|speaker:0|> Thank you.",
++ "response_format": "wav"
++}
++```
++
++For streamed audio, set `"stream": true` and `"response_format": "pcm"`. To condition speakers on reference audio, create a catalog entry whose reference transcripts have matching tags, such as `<|speaker:0|> First reference transcript.` and `<|speaker:15|> Second reference transcript.`, then select its name with `"voice": "conversation"`. The HTTP `speaker` field remains an alias for that catalog name; dialogue speaker IDs belong in the text tags.
++
++One catalog entry still accepts at most **four reference passages**, even though there are sixteen valid speaker IDs. Each passage keeps its own transcript and code rows. Reusing a catalog entry preserves its exact reference prefix; changing a reference speaker tag changes that prefix. Speaker tags specify the prompt's roles; this does not guarantee distinguishable voices without suitable references or establish voice similarity.
++
++The public [Drama 3 MLX prompt implementation](https://github.com/Sogni-AI/drama3-mlx/blob/ecc235039ccd5f982f66a6982184f57432354ab4/drama3_mlx/prompt.py) provides a behavior reference for these speaker markers. TensorFold uses its existing native prompt builder.
+
+ ## Writing a voice file
+
+diff --git a/zig/src/families/drama3/prompt.zig b/zig/src/families/drama3/prompt.zig
+index e9e40b6c..0f069788 100644
+--- a/zig/src/families/drama3/prompt.zig
++++ b/zig/src/families/drama3/prompt.zig
+@@ -226,3 +226,32 @@ test "reference cache: last reference code and equal-length changed tower lookah
+ first.frames[saved_rows][0] += 1;
+ try t.expect(!try prefix.begin(t.allocator, first.frames, saved_rows));
+ }
++
++test "reference cache: a reference speaker change misses while request speaker changes reuse the prefix" {
++ const t = std.testing;
++ const cache = @import("reference_cache.zig");
++ var prefix: cache.Prefix = .{};
++ defer prefix.deinit(t.allocator);
++ const codes = [_][cfg.codebooks]i32{@splat(1)};
++ var refs = [_]Reference{
++ .{ .transcript = "<|speaker:1|> First.", .codes = &codes },
++ .{ .transcript = "<|speaker:15|> Second.", .codes = &codes },
++ };
++ const first = try build(t.allocator, ByteTokenizer{}, "<|speaker:1|> 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();
++
++ const request = try build(t.allocator, ByteTokenizer{}, "<|speaker:15|> Two. <|speaker:1|> Three.", &refs);
++ defer t.allocator.free(request.frames);
++ try t.expectEqual(first.reference_rows, request.reference_rows);
++ try t.expect(try prefix.begin(t.allocator, request.frames, saved_rows));
++
++ refs[0].transcript = "<|speaker:2|> First.";
++ const changed = try build(t.allocator, ByteTokenizer{}, "<|speaker:1|> One.", &refs);
++ defer t.allocator.free(changed.frames);
++ try t.expectEqual(first.frames.len, changed.frames.len);
++ try t.expectEqual(first.reference_rows, changed.reference_rows);
++ try t.expect(!try prefix.begin(t.allocator, changed.frames, saved_rows));
++}
+diff --git a/zig/src/server/drama_prompt.zig b/zig/src/server/drama_prompt.zig
+index 7acfadc1..7421b0de 100644
+--- a/zig/src/server/drama_prompt.zig
++++ b/zig/src/server/drama_prompt.zig
+@@ -73,36 +73,13 @@ fn speakerId(digits: []const u8) ?u8 {
+ return if (n <= max_speaker_id) n else null;
+ }
+
+-/// Why input cannot be spoken, in the reference's words and order, or null.
++/// Why input cannot be spoken; canonical speaker tags 0..15 are accepted in requests and references.
+ pub fn problem(a: Allocator, input: []const u8) Allocator.Error!?[]const u8 {
+- // speech_request.py: well-formed tags naming a speaker other than 0 (malformed ones are the builder's)
+- var others: u16 = 0;
+- var at: usize = 0;
+- while (std.mem.indexOfPos(u8, input, at, "<|speaker:")) |start| {
+- if (tagAt(input, start)) |tag| {
+- if (speakerId(tag.digits)) |n| if (n != 0) {
+- others |= @as(u16, 1) << @intCast(n);
+- };
+- at = tag.end;
+- } else at = start + 1;
+- }
+- if (others != 0) {
+- var list: std.ArrayList(u8) = .empty;
+- try list.appendSlice(a, error_prefix ++ " only speaker 0 is supported; got speaker tags [");
+- var first = true;
+- for (1..max_speaker_id + 1) |n| if (others & (@as(u16, 1) << @intCast(n)) != 0) {
+- if (!first) try list.appendSlice(a, ", ");
+- try list.print(a, "{d}", .{n});
+- first = false;
+- };
+- try list.append(a, ']');
+- return list.items;
+- }
+ // prompt.py's validate_caller_text(text, "input")
+ if (trimStart(input).len == 0) return error_prefix ++ " input: text must not be empty";
+ for (structural_tokens) |token| if (std.mem.indexOf(u8, input, token) != null)
+ return try std.fmt.allocPrint(a, error_prefix ++ " input: {s} is written by the prompt builder and must not appear in caller text", .{token});
+- at = 0;
++ var at: usize = 0;
+ while (std.mem.indexOfPos(u8, input, at, "<|speaker")) |start| {
+ const tag = tagAt(input, start);
+ if (tag == null or speakerId(tag.?.digits) == null)
+@@ -147,6 +124,7 @@ test "speaker 0 tags untagged text; a leading tag after white space is kept" {
+ .{ "\n<|speaker:0|> hi", "\n<|speaker:0|> hi" },
+ .{ "\u{3000}<|speaker:0|> hi", "\u{3000}<|speaker:0|> hi" },
+ .{ "a <|speaker:0|> b", "<|speaker:0|> a <|speaker:0|> b" },
++ .{ "a <|speaker:15|> b", "<|speaker:0|> a <|speaker:15|> b" },
+ .{ "\x1c<|speaker:0|> hi", "<|speaker:0|> \x1c<|speaker:0|> hi" },
+ .{ "\x1f", "<|speaker:0|> \x1f" },
+ };
+@@ -154,20 +132,22 @@ test "speaker 0 tags untagged text; a leading tag after white space is kept" {
+ try std.testing.expect(try problem(a, c[0]) == null);
+ try std.testing.expectEqualStrings(c[1], try tagged(a, c[0]));
+ }
++ for (0..16) |id| {
++ const dialogue = try std.fmt.allocPrint(a, "\u{3000}<|speaker:{d}|> First. <|speaker:0|> Reply.", .{id});
++ try std.testing.expect(try problem(a, dialogue) == null);
++ try std.testing.expectEqualStrings(dialogue, try tagged(a, dialogue));
++ }
+ }
+
+-test "input the reference refuses" {
++test "empty input, malformed speaker tags and structural tokens are refused" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const a = arena.allocator();
+- const malformed = [_][]const u8{ "<|speaker:99|> x", "<|speaker:16|> x", "a <|speaker:99|> b", "<|speaker:01|> x", "<|speaker:00|> x", "<|speaker:|> x", "<|speaker0|> x", "hi <|speaker", "<|speaker:abc|> x", "<|speaker:0> x", " <|speaker:abc|> hi" };
++ const malformed = [_][]const u8{ "<|speaker:99|> x", "<|speaker:16|> x", "a <|speaker:99|> b", "<|speaker:01|> x", "<|speaker:00|> x", "<|speaker:|> x", "<|speaker0|> x", "hi <|speaker", "<|speaker:abc|> x", "<|speaker:0> x", " <|speaker:abc|> hi", "<|speaker:2|> x <|speaker:01|>" };
+ for (malformed) |text| {
+ const message = (try problem(a, text)) orelse return error.TestUnexpectedResult;
+ try std.testing.expect(std.mem.startsWith(u8, message, "Drama 3 request: input: speaker tags must be written <|speaker:N|> with N in 0..15 and no leading zero; got "));
+ }
+- try std.testing.expectEqualStrings("Drama 3 request: only speaker 0 is supported; got speaker tags [1, 15]", (try problem(a, "<|speaker:15|> a <|speaker:1|> b <|speaker:1|> c <|speaker:0|> d")).?);
+- // other speakers are refused before malformed tags, as speech_request.py checks them first
+- try std.testing.expectEqualStrings("Drama 3 request: only speaker 0 is supported; got speaker tags [2]", (try problem(a, "<|speaker:2|> x <|speaker:01|>")).?);
+ for ([_][]const u8{ "", " ", "\u{3000}\u{2028}\t" }) |blank|
+ try std.testing.expectEqualStrings("Drama 3 request: input: text must not be empty", (try problem(a, blank)).?);
+ for (structural_tokens) |token| {
+diff --git a/zig/src/server/drama_voice_tool.zig b/zig/src/server/drama_voice_tool.zig
+index 4a03e0a7..aacdb99f 100644
+--- a/zig/src/server/drama_voice_tool.zig
++++ b/zig/src/server/drama_voice_tool.zig
+@@ -263,7 +263,7 @@ test "voice tool: a synthetic tone's encoder codes become an attested file the c
+ const out = try c.path("tone.json");
+ try c.tmp.dir.writeFile(std.testing.io, .{ .sub_path = "unrelated.txt", .data = "unrelated" });
+ try c.tmp.dir.symLink(std.testing.io, "unrelated.txt", "tone.json", .{});
+- const texts = [_][]const u8{ "A small cloud passes.", "Rain taps on a tin roof." };
++ const texts = [_][]const u8{ "<|speaker:0|> A small cloud passes.", "\u{3000}<|speaker:15|> Rain taps on a tin roof." };
+ try std.testing.expectEqual(@as(u8, 0), try c.call(&.{&.{
+ "--codec", "synthetic.pth", "--generate", encoder,
+ "--ref-audio", wav, "--ref-text", texts[0],
+diff --git a/zig/src/server/root.zig b/zig/src/server/root.zig
+index 4d46119e..ebcb27e1 100644
+--- a/zig/src/server/root.zig
++++ b/zig/src/server/root.zig
+@@ -54,6 +54,7 @@ test {
+ if (comptime @import("builtin").os.tag != .windows) _ = @import("speech_cfg_test.zig");
+ if (comptime @import("builtin").os.tag != .windows) _ = @import("speech_stream_test.zig"); // over a Unix socket pair
+ if (comptime @import("builtin").os.tag != .windows) _ = @import("speech_sampling_test.zig");
++ if (comptime @import("builtin").os.tag != .windows) _ = @import("speech_speakers_test.zig");
+ _ = @import("drama_voice_tool.zig");
+ _ = @import("speech_redraw.zig");
+ if (comptime @import("builtin").os.tag != .windows) _ = @import("speech_redraw_test.zig"); // over a Unix socket pair
+diff --git a/zig/src/server/speech_speakers_test.zig b/zig/src/server/speech_speakers_test.zig
+new file mode 100644
+index 00000000..f39d5d20
+--- /dev/null
++++ b/zig/src/server/speech_speakers_test.zig
+@@ -0,0 +1,132 @@
++//! Speaker tags through catalog loading and HTTP lowering, checked against independently spelled prompt rows.
++const std = @import("std");
++const voice = @import("drama_voice.zig");
++const json = @import("json");
++const wire = @import("speech_stream_test.zig");
++
++const dialogue = "\u{3000}<|speaker:15|> Wait for me. <|speaker:0|> I will. <|speaker:15|> Thanks.";
++const catalog =
++ \\{"format":"drama3-http-voice/1","source":{"kind":"synthetic","note":"Generated parser fixtures."},
++ \\"references":[{"text":"A small cloud passes.","codes":[[7,1,2,3,4,5,6,7,8,9]]},
++ \\{"text":"\u3000<|speaker:15|> Rain taps.","codes":[[17,9,8,7,6,5,4,3,2,1]]}]}
++;
++const malformed = [_][]const u8{
++ "<|speaker:15|> Fine. <|speaker:16|> Outside.",
++ "<|speaker:1|> Fine. <|speaker:01|> Leading zero.",
++ "<|speaker:-1|> Negative.",
++ "<|speaker:\u{661}|> Non-ASCII digit.",
++ "<|speaker:999999999999999999999|> Overflow.",
++ "<|speaker:15|> Partial <|speaker:",
++ "<|speaker:15|> Inject <|im_end|>",
++ "<|speaker:15|> Inject <|semantic:1|>",
++};
++
++fn appendText(a: std.mem.Allocator, rows: *std.ArrayList([11]i32), text: []const u8) !void {
++ for (text) |byte| {
++ var row: [11]i32 = @splat(0);
++ row[0] = byte;
++ try rows.append(a, row);
++ }
++}
++
++const Expected = struct { rows: []const [11]i32, reference_rows: u32 };
++
++fn expected(a: std.mem.Allocator, references: bool) !Expected {
++ var rows: std.ArrayList([11]i32) = .empty;
++ if (references) {
++ try appendText(a, &rows, "<|im_start|>system\nconvert the provided text to speech reference to the following:" ++
++ "\n\n[Reference 1]\nText: <|speaker:0|> A small cloud passes.\nSpeech: ");
++ try rows.append(a, .{ 1007, 7, 1, 2, 3, 4, 5, 6, 7, 8, 9 });
++ try appendText(a, &rows, "\n\n[Reference 2]\nText: <|speaker:15|>\u{3000} Rain taps.\nSpeech: ");
++ try rows.append(a, .{ 1017, 17, 9, 8, 7, 6, 5, 4, 3, 2, 1 });
++ try appendText(a, &rows, "<|im_end|>\n<|im_start|>user\n");
++ } else try appendText(a, &rows, "<|im_start|>system\nconvert the provided text to speech<|im_end|>\n<|im_start|>user\n");
++ const boundary: u32 = if (references) @intCast(rows.items.len) else 0;
++ try appendText(a, &rows, dialogue ++ "<|im_end|>\n<|im_start|>assistant\n<|voice|>");
++ return .{ .rows = rows.items, .reference_rows = boundary };
++}
++
++test "speakers: whole and streamed HTTP preserve dialogue, catalog tags and exact code rows" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ for ([_]bool{ false, true }) |references| {
++ const voices = [_]voice.Voice{.{ .name = "duo", .references = if (references) try voice.parse(a, catalog) else &.{} }};
++ const want = try expected(a, references);
++ for ([_]bool{ false, true }) |stream| {
++ var script: wire.Script = .{ .steps = &.{wire.frame}, .voices = &voices, .expected_rows = want.rows, .inline_ = true };
++ const request = try std.json.Stringify.valueAlloc(a, .{
++ .model = "m",
++ .input = dialogue,
++ .voice = if (references) "duo" else "default",
++ .response_format = "pcm",
++ .stream = stream,
++ }, .{});
++ var s: wire.Served = undefined;
++ try s.open(&script, .{ .served_name = "m", .model_ids = &.{"m"} }, "", "HTTP/1.1", request, 2000);
++ defer s.deinit();
++ const head = try a.dupe(u8, try s.readHead());
++ const rest = try s.readRest();
++ s.join(&script);
++ try wire.expectHeader(head, " 200 ");
++ try std.testing.expectEqual(@as(usize, 1), script.takes);
++ try std.testing.expect(script.rows_equal);
++ try std.testing.expectEqual(want.reference_rows, script.reference_rows[0]);
++ const pcm = if (stream) blk: {
++ try wire.expectHeader(head, "Transfer-Encoding: chunked\r\n");
++ try std.testing.expect(std.mem.startsWith(u8, rest, "1000\r\n"));
++ try std.testing.expect(std.mem.endsWith(u8, rest, "\r\n0\r\n\r\n"));
++ break :blk rest["1000\r\n".len .. rest.len - "\r\n0\r\n\r\n".len];
++ } else blk: {
++ try wire.expectHeader(head, "Content-Length: 4096\r\n");
++ break :blk rest;
++ };
++ try std.testing.expectEqual(@as(usize, wire.frame * 2), pcm.len);
++ for (pcm, 0..) |byte, i| try std.testing.expectEqual(@as(u8, if (i % 2 == 0) 0xff else 0x3f), byte);
++ }
++ }
++}
++
++test "speakers: catalog accepts every canonical ID and refuses malformed or structural tags" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ const doc = (try json.parse(a, catalog)).ok;
++ const reference = doc.get("references").?.array[1].object;
++ for (0..16) |id| {
++ const text = try std.fmt.allocPrint(a, "\u{3000}<|speaker:{d}|> Rain taps.", .{id});
++ try reference.put(a, "text", .{ .string = text });
++ const refs = try voice.parse(a, try json.stringify(a, doc, .{}));
++ try std.testing.expectEqualStrings(text, refs[1].transcript);
++ try std.testing.expectEqualSlices(i32, &.{ 17, 9, 8, 7, 6, 5, 4, 3, 2, 1 }, &refs[1].codes[0]);
++ }
++ for (malformed) |text| {
++ try reference.put(a, "text", .{ .string = text });
++ try std.testing.expectError(error.BadVoice, voice.parse(a, try json.stringify(a, doc, .{})));
++ }
++}
++
++test "speakers: malformed dialogue refuses before engine submission with default or catalog voice" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ const voices = [_]voice.Voice{.{ .name = "duo", .references = try voice.parse(a, catalog) }};
++ for ([_][]const u8{ "default", "duo" }) |name| for (malformed) |input| {
++ var script: wire.Script = .{ .steps = &.{wire.frame}, .voices = &voices, .inline_ = true };
++ const request = try std.json.Stringify.valueAlloc(a, .{
++ .model = "m",
++ .input = input,
++ .voice = name,
++ .response_format = "pcm",
++ .stream = true,
++ }, .{});
++ var s: wire.Served = undefined;
++ try s.open(&script, .{ .served_name = "m", .model_ids = &.{"m"} }, "", "HTTP/1.1", request, 2000);
++ defer s.deinit();
++ const head = try a.dupe(u8, try s.readHead());
++ _ = try s.readRest();
++ s.join(&script);
++ try wire.expectHeader(head, " 400 ");
++ try std.testing.expectEqual(@as(usize, 0), script.takes);
++ };
++}
+--
+2.46.0
+
diff --git a/engine/nvfp4/macos/patches/SHA256SUMS b/engine/nvfp4/macos/patches/SHA256SUMS
index 31ddc24..6689758 100644
--- a/engine/nvfp4/macos/patches/SHA256SUMS
+++ b/engine/nvfp4/macos/patches/SHA256SUMS
@@ -3,3 +3,4 @@ f282318f0e8dae43b19d1fa93ec05691696a0352eaeac41f9ffc334690d8b388 0001-drama3-me
794a5b6008487ec705cadb098eb1c15737da743aee0855b610510506a0ce38db 0003-shared-cli-extension-point.patch
c9c1406dc344ab359693b03584184119569ad05c686606d2f4c976eb667ce54d 0004-speech-sampling-controls.patch
158ac17113ca3ffd04706782189a14706d4c63e343e40b81af287bae1b55cefc 0005-sampling-json-boundaries.patch
+5d7d2cd3a4ff37b0009a19c49c1078db6977febe63a20b7301ec417afd07b903 0006-dialogue-speakers.patch
diff --git a/engine/nvfp4/patches/0003-dialogue-speakers.patch b/engine/nvfp4/patches/0003-dialogue-speakers.patch
new file mode 100644
index 0000000..966ee1c
--- /dev/null
+++ b/engine/nvfp4/patches/0003-dialogue-speakers.patch
@@ -0,0 +1,642 @@
+From 9e782dc995b191829c02be6f8779faac164f53d5 Mon Sep 17 00:00:00 2001
+From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
+Date: Sun, 25 Oct 2026 11:59:05 +0800
+Subject: [PATCH] Drama speech: preserve dialogue speakers across long-form
+ requests
+
+---
+ CHANGELOG.md | 2 +
+ docs/drama-voices.md | 16 +-
+ zig/src/server/drama_prompt.zig | 68 +++----
+ zig/src/server/drama_voice.zig | 11 +-
+ zig/src/server/drama_voice_multi_test.zig | 6 +-
+ zig/src/server/drama_voice_test.zig | 20 ++-
+ zig/src/server/root.zig | 1 +
+ zig/src/server/speech_long.zig | 25 ++-
+ zig/src/server/speech_speakers_test.zig | 206 ++++++++++++++++++++++
+ zig/src/server/voice_cli.zig | 11 +-
+ 10 files changed, 305 insertions(+), 61 deletions(-)
+ create mode 100644 zig/src/server/speech_speakers_test.zig
+
+diff --git a/CHANGELOG.md b/CHANGELOG.md
+index 8c510990..63a96543 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 accepts dialogue tags `<|speaker:0|>` through `<|speaker:15|>` in requests and reference transcripts. Long-form continuations, history and redraws preserve the active speaker; untagged requests still default to speaker 0. See [dialogue speakers](docs/drama-voices.md#dialogue-speakers).
++
+ - speech: Drama requests accept `temperature`, `top_p`, `top_k` (1 through 64) and exact unsigned 64-bit `seed`. Whole replies, streams, references, guidance and long-form segments retain the requested settings; omitted or null fields keep existing defaults. Explicit seeds advance by each segment's attempt number on redraws. See [speech sampling](docs/speech-sampling.md).
+
+ - speech: long-form Drama 3 splits preserve complete special tokens, bracketed cues and nested paired direction tags, even when one span exceeds the character target. CJK punctuation supplies sentence and clause boundaries without requiring spaces, and topic-shift markers inside protected spans cannot split them. Existing input, prompt-token, segment and frame limits still apply ([docs](docs/drama-voices.md#long-form-replies)).
+diff --git a/docs/drama-voices.md b/docs/drama-voices.md
+index 06037d30..9fce2dac 100644
+--- a/docs/drama-voices.md
++++ b/docs/drama-voices.md
+@@ -117,12 +117,26 @@ tensorfold serve DRAMA3_DIR --voices voices/ --default-voice NAME
+ ```
+
+ `references: [{"text": ..., "vq_codes": [[...], ...]}]` sends references inline, as the reference accepts them. A named voice's references follow the request's own references.
+-Refusals carry the reference's messages, in its order.
++Apart from accepting speaker IDs 0 through 15, refusals carry the reference's messages, in its order.
+ Some refusals are the native server's own:
+
+ - Reference audio (`audio_path`, `ref_audio`, `audio`, `data`) is refused: encode it with the tool and send `vq_codes`.
+ - Unknown reference fields are refused, not dropped.
+
++## Dialogue speakers
++
++Input and reference transcripts accept canonical `<|speaker:N|>` tags with decimal IDs `0` through `15`. Untagged text defaults to speaker 0. Leading zeros, negative IDs, non-ASCII digits, incomplete tags and IDs above 15 are rejected.
++
++```json
++{"model": "drama-3", "input": "<|speaker:0|> Is the gate open? <|speaker:15|> Yes, come through. <|speaker:0|> Thank you.", "voice": "conversation"}
++```
++
++Use matching tags in the catalog entry's reference transcripts, or in inline `references`, to associate reference audio with the dialogue speakers. The HTTP `speaker` field remains an alias for the catalog name in `voice`; speaker IDs belong in text tags. All existing code, digest, attestation and prompt-budget checks still apply.
++
++Whole replies and PCM streams accept the same dialogue. With long-form enabled, a segment without a leading tag continues the last speaker from the preceding text, including across paragraph shifts. History references, guidance prompts and plain redraws retain that speaker. A new request starts with the usual speaker-0 default.
++
++The public [Drama 3 MLX prompt implementation](https://github.com/Sogni-AI/drama3-mlx/blob/ecc235039ccd5f982f66a6982184f57432354ab4/drama3_mlx/prompt.py) is a behavior reference for the speaker markers. This native change establishes prompt support; voice similarity and acoustic quality still require listening tests with suitable references.
++
+ ## Expressiveness
+
+ The model card's documented controls are written in the input text:
+diff --git a/zig/src/server/drama_prompt.zig b/zig/src/server/drama_prompt.zig
+index 11c90d57..762cb2f5 100644
+--- a/zig/src/server/drama_prompt.zig
++++ b/zig/src/server/drama_prompt.zig
+@@ -79,40 +79,11 @@ fn speakerId(digits: []const u8) ?u8 {
+ return if (n <= max_speaker_id) n else null;
+ }
+
+-/// Why ``input`` (valid UTF-8) cannot be spoken, in the reference's words, or null when it can.
+-/// The order is the reference's: other speakers first, then ``validate_caller_text``.
++/// Why input (valid UTF-8) cannot be spoken; canonical speaker tags 0..15 are accepted.
+ pub fn problem(a: Allocator, input: []const u8) Allocator.Error!?[]const u8 {
+- if (try otherSpeakers(a, &.{input})) |message| return message;
+ return callerText(a, input, "input");
+ }
+
+-/// speech_request.py's refusal of well-formed tags naming speakers other than 0 (malformed ones are the builder's).
+-pub fn otherSpeakers(a: Allocator, texts: []const []const u8) Allocator.Error!?[]const u8 {
+- var others: u16 = 0;
+- for (texts) |text| {
+- var at: usize = 0;
+- while (std.mem.indexOfPos(u8, text, at, "<|speaker:")) |start| {
+- if (tagAt(text, start)) |tag| {
+- if (speakerId(tag.digits)) |n| if (n != 0) {
+- others |= @as(u16, 1) << @intCast(n);
+- };
+- at = tag.end;
+- } else at = start + 1;
+- }
+- }
+- if (others == 0) return null;
+- var list: std.ArrayList(u8) = .empty;
+- try list.appendSlice(a, error_prefix ++ " only speaker 0 is supported; got speaker tags [");
+- var first = true;
+- for (1..max_speaker_id + 1) |n| if (others & (@as(u16, 1) << @intCast(n)) != 0) {
+- if (!first) try list.appendSlice(a, ", ");
+- try list.print(a, "{d}", .{n});
+- first = false;
+- };
+- try list.append(a, ']');
+- return list.items;
+-}
+-
+ /// prompt.py's ``validate_caller_text(text, location)`` for valid UTF-8 ``text``.
+ pub fn callerText(a: Allocator, text: []const u8, location: []const u8) Allocator.Error!?[]const u8 {
+ if (trimStart(text).len == 0) return try std.fmt.allocPrint(a, error_prefix ++ " {s}: text must not be empty", .{location});
+@@ -134,6 +105,20 @@ pub fn tagged(a: Allocator, input: []const u8) Allocator.Error![]const u8 {
+ return std.mem.concat(a, u8, &.{ "<|speaker:0|> ", input });
+ }
+
++/// Continues checked text with the last speaker in its checked preceding text; keeps speaker 0 implicit.
++pub fn continued(a: Allocator, input: []const u8, preceding: []const u8) Allocator.Error![]const u8 {
++ if (tagAt(trimStart(input), 0) != null) return input;
++ var speaker: u8 = 0;
++ var at: usize = 0;
++ while (std.mem.indexOfPos(u8, preceding, at, "<|speaker:")) |start| {
++ const tag = tagAt(preceding, start).?;
++ speaker = speakerId(tag.digits).?;
++ at = tag.end;
++ }
++ if (speaker == 0) return input;
++ return std.fmt.allocPrint(a, "<|speaker:{d}|> {s}", .{ speaker, input });
++}
++
+ /// The prompt's segments for a checked ``input`` (``problem`` returned null), each tokenized on its own.
+ pub fn segments(a: Allocator, input: []const u8) Allocator.Error![segment_count][]const u8 {
+ return .{ "<|im_start|>system\n", tts_instruction, message_end, "<|im_start|>user\n", try tagged(a, input), message_end, generation_start };
+@@ -205,6 +190,7 @@ test "speaker 0 tags untagged text; a leading tag after white space is kept" {
+ .{ "\n<|speaker:0|> hi", "\n<|speaker:0|> hi" },
+ .{ "\u{3000}<|speaker:0|> hi", "\u{3000}<|speaker:0|> hi" },
+ .{ "a <|speaker:0|> b", "<|speaker:0|> a <|speaker:0|> b" },
++ .{ "a <|speaker:15|> b", "<|speaker:0|> a <|speaker:15|> b" },
+ .{ "\x1c<|speaker:0|> hi", "<|speaker:0|> \x1c<|speaker:0|> hi" },
+ .{ "\x1f", "<|speaker:0|> \x1f" },
+ };
+@@ -212,9 +198,14 @@ test "speaker 0 tags untagged text; a leading tag after white space is kept" {
+ try std.testing.expect(try problem(a, c[0]) == null);
+ try std.testing.expectEqualStrings(c[1], try tagged(a, c[0]));
+ }
++ for (0..16) |id| {
++ const text = try std.fmt.allocPrint(a, "\u{3000}<|speaker:{d}|> First. <|speaker:0|> Reply.", .{id});
++ try std.testing.expect(try problem(a, text) == null);
++ try std.testing.expectEqualStrings(text, try tagged(a, text));
++ }
+ }
+
+-test "input the reference refuses" {
++test "empty input, malformed speaker tags and structural tokens are refused" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const a = arena.allocator();
+@@ -223,9 +214,7 @@ test "input the reference refuses" {
+ const message = (try problem(a, text)) orelse return error.TestUnexpectedResult;
+ try std.testing.expect(std.mem.startsWith(u8, message, "Drama 3 request: input: speaker tags must be written <|speaker:N|> with N in 0..15 and no leading zero; got "));
+ }
+- try std.testing.expectEqualStrings("Drama 3 request: only speaker 0 is supported; got speaker tags [1, 15]", (try problem(a, "<|speaker:15|> a <|speaker:1|> b <|speaker:1|> c <|speaker:0|> d")).?);
+- // other speakers are refused before malformed tags, as speech_request.py checks them first
+- try std.testing.expectEqualStrings("Drama 3 request: only speaker 0 is supported; got speaker tags [2]", (try problem(a, "<|speaker:2|> x <|speaker:01|>")).?);
++ try std.testing.expectStringStartsWith((try problem(a, "<|speaker:2|> x <|speaker:01|>")).?, error_prefix ++ " input: speaker tags must be written");
+ for ([_][]const u8{ "", " ", "\u{3000}\u{2028}\t" }) |blank|
+ try std.testing.expectEqualStrings("Drama 3 request: input: text must not be empty", (try problem(a, blank)).?);
+ for (structural_tokens) |token| {
+@@ -236,3 +225,14 @@ test "input the reference refuses" {
+ for ([_][]const u8{ "hi <|audio_separator|> there", "<|fim_prefix|> hi", "<|speaker:0|> current part<|endofturn|>next part" }) |content|
+ try std.testing.expect(try problem(a, content) == null);
+ }
++
++test "continued speech inherits the last tag, explicit tags override it, and speaker zero stays implicit" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ try std.testing.expectEqualStrings("<|speaker:15|> Next.", try continued(a, "Next.", "<|speaker:15|> First."));
++ try std.testing.expectEqualStrings("<|speaker:2|> Next.", try continued(a, "Next.", "<|speaker:15|> First. <|speaker:2|> Second."));
++ try std.testing.expectEqualStrings("\u{3000}<|speaker:1|> Next.", try continued(a, "\u{3000}<|speaker:1|> Next.", "<|speaker:15|> First."));
++ try std.testing.expectEqualStrings("Next.", try continued(a, "Next.", "First."));
++ try std.testing.expectEqualStrings("Next.", try continued(a, "Next.", "<|speaker:15|> First. <|speaker:0|> Second."));
++}
+diff --git a/zig/src/server/drama_voice.zig b/zig/src/server/drama_voice.zig
+index f37549b4..71c3be3b 100644
+--- a/zig/src/server/drama_voice.zig
++++ b/zig/src/server/drama_voice.zig
+@@ -46,16 +46,13 @@ pub fn lower(cx: *Cx, input: []const u8, sent: Value, voice: []const Reference)
+ return cx.fail(.request, "unknown speech reference field: references.{d}.{s}", .{ i, key });
+ }
+ const a = cx.a;
+- const texts = try a.alloc([]const u8, 1 + items.len + voice.len);
+- texts[0] = input;
+- for (items, texts[1..][0..items.len], 0..) |item, *text, i| {
++ const texts = try a.alloc([]const u8, items.len);
++ for (items, texts, 0..) |item, *text, i| {
+ // descriptor.get("text") or "": a falsy text is an empty transcript
+ const t = item.get("text") orelse Value.null;
+ text.* = if (!t.truthy()) "" else if (t == .string) t.string else return cx.fail(.request, prefix ++ " references.{d}.text must be a string", .{i});
+ if (!std.unicode.utf8ValidateSlice(text.*)) return cx.fail(.request, prefix ++ " references.{d}.text must be valid UTF-8 text", .{i});
+ }
+- for (voice, texts[1 + items.len ..]) |v, *text| text.* = v.text;
+- if (try drama_prompt.otherSpeakers(a, texts)) |message| return cx.refuse(message);
+ const grids = try a.alloc(Grid, items.len);
+ for (items, grids, 0..) |item, *g, i| {
+ if (item.field("vq_codes")) |codes| {
+@@ -67,7 +64,7 @@ pub fn lower(cx: *Cx, input: []const u8, sent: Value, voice: []const Reference)
+ const out = try a.alloc(Reference, items.len + voice.len);
+ for (grids, out[0..items.len], 0..) |g, *reference, i| {
+ const location = try std.fmt.allocPrint(a, "references.{d}", .{i});
+- reference.* = .{ .text = texts[1 + i], .frames = undefined };
++ reference.* = .{ .text = texts[i], .frames = undefined };
+ if (try drama_prompt.callerText(a, try drama_prompt.tagged(a, reference.text), location)) |message| return cx.refuse(message);
+ reference.frames = try frames(a, g) orelse return cx.refuse(try codesProblem(a, g, location));
+ }
+@@ -194,7 +191,7 @@ fn fileReference(a: Allocator, doc: Value, problem: *[]const u8) Allocator.Error
+ const codes = if (g) |x| try frames(a, x) else null;
+ if (!std.unicode.utf8ValidateSlice(text)) {
+ problem.* = "text is not UTF-8";
+- } else if (try drama_prompt.otherSpeakers(a, &.{text}) orelse try drama_prompt.callerText(a, try drama_prompt.tagged(a, text), "text")) |message| {
++ } else if (try drama_prompt.callerText(a, try drama_prompt.tagged(a, text), "text")) |message| {
+ problem.* = message;
+ } else if (codes == null) {
+ problem.* = "vq_codes is not a [10, frames] grid of codes in [0, 4096)";
+diff --git a/zig/src/server/drama_voice_multi_test.zig b/zig/src/server/drama_voice_multi_test.zig
+index 780160df..7f89b474 100644
+--- a/zig/src/server/drama_voice_multi_test.zig
++++ b/zig/src/server/drama_voice_multi_test.zig
+@@ -19,7 +19,7 @@ fn passages() [3]Reference {
+ const second = [_]Codes{.{ 20, 21, 22, 23, 24, 25, 26, 27, 28, 29 }};
+ const own = [_]Codes{ .{ 30, 31, 32, 33, 34, 35, 36, 37, 38, 39 }, .{ 40, 41, 42, 43, 44, 45, 46, 47, 48, 49 }, .{ 7, 7, 7, 7, 7, 7, 7, 7, 7, 7 } };
+ };
+- return .{ .{ .text = "The first passage.", .frames = &S.first }, .{ .text = "A second one, then.", .frames = &S.second }, .{ .text = "And the voice's own.", .frames = &S.own } };
++ return .{ .{ .text = "The first passage.", .frames = &S.first }, .{ .text = "<|speaker:15|> A second one, then.", .frames = &S.second }, .{ .text = "And the voice's own.", .frames = &S.own } };
+ }
+
+ /// The [codebooks, frames] grid as a voice file writes it.
+@@ -94,7 +94,7 @@ test "a /2 voice builds the rows a request's references followed by a /1 voice b
+ try expectRows(try rows(a, input, "null", own), try rows(a, input, "null", try load(a, try several(a, entries[2..]))));
+ }
+
+-test "a /2 file is refused for an empty list, a reference without its clip's digest, wrong codes or another speaker" {
++test "a /2 file is refused for an empty list, a reference without its clip's digest, wrong codes or a malformed tag" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const a = arena.allocator();
+@@ -106,7 +106,7 @@ test "a /2 file is refused for an empty list, a reference without its clip's dig
+ .{ try several(a, &.{ good, try fields(a, p[1], "") }), "references.1: audio_sha256 is not a clip's sha256 (64 lowercase hex digits)" },
+ .{ try several(a, &.{ good, try fields(a, p[1], clip[1..] ++ "A") }), "references.1: audio_sha256 is not a clip's sha256 (64 lowercase hex digits)" },
+ .{ try several(a, &.{ good, wrong }), "references.1: codes_sha256 does not match vq_codes" },
+- .{ try several(a, &.{try fields(a, .{ .text = "<|speaker:3|> x", .frames = p[0].frames }, clip)}), "references.0: Drama 3 request: only speaker 0 is supported; got speaker tags [3]" },
++ .{ try several(a, &.{try fields(a, .{ .text = "<|speaker:16|> x", .frames = p[0].frames }, clip)}), "references.0: Drama 3 request: text: speaker tags must be written" },
+ .{ try std.mem.replaceOwned(u8, a, try several(a, &.{good}), "voice/2", "voice/3"), "format is not drama3-voice/1 or drama3-voice/2" },
+ .{ try std.mem.replaceOwned(u8, a, try several(a, &.{good}), source, "null"), "source must say whose voice this is" },
+ };
+diff --git a/zig/src/server/drama_voice_test.zig b/zig/src/server/drama_voice_test.zig
+index fb719902..27c83491 100644
+--- a/zig/src/server/drama_voice_test.zig
++++ b/zig/src/server/drama_voice_test.zig
+@@ -14,16 +14,26 @@ const Reference = drama_prompt.Reference;
+
+ const fixture = @embedFile("drama_voice_fixture.json");
+
+-test "references are refused with the reference's messages, in its order" {
++test "references keep the pinned refusals except the two speaker-only cases this native extension accepts" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const a = arena.allocator();
+ const doc = (try json.parse(a, fixture)).ok;
+ const cases = doc.get("refusals").?.array;
+ try std.testing.expect(cases.len >= 20);
++ var extended: usize = 0;
+ for (cases) |case| {
+ var cx: Cx = .{ .a = a };
+ const got = drama_voice.lower(&cx, case.strField("input").?, case.get("references").?, &.{});
++ const name = case.strField("name").?;
++ if (std.mem.eql(u8, name, "other-speaker") or std.mem.eql(u8, name, "other-speaker-input")) {
++ try std.testing.expectStringStartsWith(case.strField("message").?, "Drama 3 request: only speaker 0");
++ const refs = try got;
++ try std.testing.expectEqual(@as(usize, 1), refs.len);
++ try std.testing.expectEqualStrings(case.get("references").?.array[0].strField("text").?, refs[0].text);
++ extended += 1;
++ continue;
++ }
+ std.testing.expectError(error.Refused, got) catch |e| {
+ std.debug.print("{s}: accepted\n", .{case.strField("name").?});
+ return e;
+@@ -33,6 +43,7 @@ test "references are refused with the reference's messages, in its order" {
+ return e;
+ };
+ }
++ try std.testing.expectEqual(@as(usize, 2), extended);
+ }
+
+ test "the server's own refusals: audio it cannot encode, fields it would drop, text that is not UTF-8" {
+@@ -72,10 +83,9 @@ test "a request's references come before its voice's; no references and no voice
+ try std.testing.expectEqualStrings("voice", both[1].text);
+ try std.testing.expectEqual(@as(usize, 1), (try drama_voice.lower(&cx, "hi", .null, &voice)).len);
+ for ([_][]const u8{ "null", "[]" }) |none| try std.testing.expectEqual(@as(usize, 0), (try drama_voice.lower(&cx, "<|voice|>", (try json.parse(a, none)).ok, &.{})).len);
+- // the voice's transcript counts in the other-speaker check, as an uploaded voice's does
++ // A catalog's nonzero speaker is preserved, as an inline reference's is.
+ const other = [_]Reference{.{ .text = "<|speaker:4|> voice", .frames = &frames }};
+- try std.testing.expectError(error.Refused, drama_voice.lower(&cx, "hi", .null, &other));
+- try std.testing.expectEqualStrings("Drama 3 request: only speaker 0 is supported; got speaker tags [4]", cx.message);
++ try std.testing.expectEqualStrings(other[0].text, (try drama_voice.lower(&cx, "hi", .null, &other))[0].text);
+ }
+
+ test "transcripts move a tag behind leading white space in front of it" {
+@@ -246,7 +256,7 @@ test "voice files: regular NAME.json files only, names, consent and digests chec
+ .{ .file = "default.json", .data = try voiceFile(a, "x", owner, true), .problem = "a voice name is" },
+ .{ .file = "-x.json", .data = try voiceFile(a, "x", owner, true), .problem = "a voice name is" },
+ .{ .file = "bad.json", .data = try voiceFile(a, "x", owner, false), .problem = "codes_sha256 does not match vq_codes" },
+- .{ .file = "tagged.json", .data = try voiceFile(a, "<|speaker:3|> x", owner, true), .problem = "only speaker 0 is supported" },
++ .{ .file = "tagged.json", .data = try voiceFile(a, "<|speaker:16|> x", owner, true), .problem = "speaker tags must be written" },
+ .{ .file = "plain.json", .data = "nope", .problem = "not JSON" },
+ .{ .file = "unsourced.json", .data = try voiceFile(a, "x", "", true), .problem = unsourced },
+ .{ .file = "flat.json", .data = try voiceFile(a, "x", "\"owner\"", true), .problem = unsourced },
+diff --git a/zig/src/server/root.zig b/zig/src/server/root.zig
+index 3a753858..6eb6e4aa 100644
+--- a/zig/src/server/root.zig
++++ b/zig/src/server/root.zig
+@@ -41,6 +41,7 @@ test {
+ _ = @import("status_routes.zig");
+ _ = @import("speech.zig");
+ _ = @import("speech_stream_test.zig");
++ _ = @import("speech_speakers_test.zig");
+ _ = @import("drama_prompt.zig");
+ _ = @import("drama_voice_test.zig");
+ _ = @import("drama_voice_multi_test.zig");
+diff --git a/zig/src/server/speech_long.zig b/zig/src/server/speech_long.zig
+index a19a5fdc..57b7b251 100644
+--- a/zig/src/server/speech_long.zig
++++ b/zig/src/server/speech_long.zig
+@@ -32,6 +32,11 @@ pub const Long = struct {
+ fn shifted(l: Long, i: usize) bool {
+ return i < l.shifts.len and l.shifts[i];
+ }
++
++ fn textFor(l: Long, a: std.mem.Allocator, i: usize, text: []const u8) ![]const u8 {
++ const start = @intFromPtr(l.segments[i].ptr) - @intFromPtr(l.input.ptr);
++ return drama_prompt.continued(a, text, l.input[0..start]);
++ }
+ };
+
+ /// The stream's trailers: the finish reason when cut short, where the segments start, and with shifts on, their pauses.
+@@ -47,16 +52,17 @@ pub fn plan(srv: *Server, cx: *Cx, sp: api.SpeechInfo, marked: drama_shift.Marke
+ if (segments.len <= 1) return null;
+ if (segments.len > drama_long.max_segments)
+ return cx.fail(.request, drama_prompt.error_prefix ++ " a long-form input in {d} segments targeting {d} characters exceeds the maximum of {d} segments", .{ segments.len, options.chars, drama_long.max_segments });
+- for (segments) |segment| {
++ const long: Long = .{ .input = input, .segments = segments, .shifts = split.shifts, .references = references, .history = options.history, .shift_pause_s = options.shift_pause_s, .sampling = sampling };
++ for (segments, 0..) |segment, i| {
+ if (try drama_prompt.problem(cx.a, segment)) |message| return cx.refuse(message);
+- const rows = drama_prompt.promptRows(cx.a, srv.text, segment, references) catch |e| return switch (e) {
++ const rows = drama_prompt.promptRows(cx.a, srv.text, try long.textFor(cx.a, i, segment), references) catch |e| return switch (e) {
+ error.OutOfMemory => error.OutOfMemory,
+ error.Template => cx.other("the tokenizer cannot encode this prompt"),
+ };
+ if (rows.tokens.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", .{ rows.tokens.len, sp.max_prompt_tokens });
+ }
+- return .{ .input = input, .segments = segments, .shifts = split.shifts, .references = references, .history = options.history, .shift_pause_s = options.shift_pause_s, .sampling = sampling };
++ return long;
+ }
+
+ /// Speaks every segment into one reply: a WAV or PCM body, or a PCM stream from the first segment's first audio.
+@@ -140,7 +146,7 @@ const Reply = struct {
+ fn speak(r: *Reply, i: usize) bool {
+ const fl: usize = r.sp.frame_length;
+ const room = @min(r.sp.max_frames, drama_long.max_frames - r.frames);
+- var text = r.long.segments[i];
++ var text = r.long.textFor(r.cx.a, i, r.long.segments[i]) catch return r.end(.other, "out of memory");
+ var rows = r.prompt(text) catch |e| return r.end(.other, if (e == error.OutOfMemory) "out of memory" else "the tokenizer cannot encode this prompt");
+ // only the first segment streams as it is made: a later one's level and pause need all of it
+ const live = r.stream and i == 0;
+@@ -195,7 +201,7 @@ const Reply = struct {
+ // a stream has sent audio before its guard can trip, so only a whole reply is redrawn
+ if (r.stream or r.redraws.policy.max == 0) break this;
+ const cause = r.redraws.trip(this.reason, this.samples, r.sp.sample_rate, text) orelse break this;
+- if (next == null) next = r.redrawText(i, rows) orelse break this;
++ if (next == null) next = r.redrawText(i, text, rows) orelse break this;
+ start_by = r.redraws.again(r.srv.io, r.id, started, cause) orelse break this;
+ text = next.?.text;
+ rows = next.?.rows;
+@@ -212,9 +218,9 @@ const Reply = struct {
+ }
+
+ /// What redraws of segment `i` speak: its `rows`, or its plain text's (--silence-redraw-plain); null if neither.
+- fn redrawText(r: *Reply, i: usize, rows: drama_prompt.Rows) ?speech_redraw.Spoken {
++ fn redrawText(r: *Reply, i: usize, spoken: []const u8, rows: drama_prompt.Rows) ?speech_redraw.Spoken {
+ const segment = r.long.segments[i];
+- if (!r.redraws.policy.plain) return .{ .text = segment, .rows = rows };
++ if (!r.redraws.policy.plain) return .{ .text = spoken, .rows = rows };
+ const why_not = why: {
+ const a = r.cx.a;
+ // the whole input's controls, so one a cut crosses goes whole and only the input's own note head counts
+@@ -224,8 +230,9 @@ const Reply = struct {
+ break :spans found;
+ };
+ const start = @intFromPtr(segment.ptr) - @intFromPtr(r.long.input.ptr);
+- const text = (speech_redraw.heard(a, r.long.input, spans, start, start + segment.len) catch break :why "out of memory") orelse return .{ .text = segment, .rows = rows };
+- if (speech_redraw.problem(a, text)) |message| break :why message;
++ const plain = (speech_redraw.heard(a, r.long.input, spans, start, start + segment.len) catch break :why "out of memory") orelse return .{ .text = spoken, .rows = rows };
++ if (speech_redraw.problem(a, plain)) |message| break :why message;
++ const text = r.long.textFor(a, i, plain) catch break :why "out of memory";
+ const cue_free = r.prompt(text) catch break :why "the tokenizer cannot encode the segment without its cues";
+ if (cue_free.tokens.len > r.sp.max_prompt_tokens) break :why "the segment without its cues does not fit the prompt";
+ log.line("speech {s} long-form segment {d}: redraws speak it without its {s}", .{ r.id, i + 1, @tagName(r.redraws.policy.plain_scope) });
+diff --git a/zig/src/server/speech_speakers_test.zig b/zig/src/server/speech_speakers_test.zig
+new file mode 100644
+index 00000000..5dd57922
+--- /dev/null
++++ b/zig/src/server/speech_speakers_test.zig
+@@ -0,0 +1,206 @@
++//! Speaker identities through actual HTTP submissions, long-form history, guidance and redraws.
++const std = @import("std");
++const server = @import("root.zig");
++const json = @import("json.zig");
++const prompt = @import("drama_prompt.zig");
++const wire = @import("speech_stream_test.zig");
++const scripted = @import("speech_long_test.zig");
++
++const suffix = "<|im_end|>\n<|im_start|>assistant\n<|voice|>";
++const plain_head = "<|im_start|>system\nconvert the provided text to speech<|im_end|>\n<|im_start|>user\n";
++const reference = "[{\"text\":\" <|speaker:15|> Reference.\",\"vq_codes\":[[7],[1],[2],[3],[4],[5],[6],[7],[8],[9]]}]";
++
++fn request(a: std.mem.Allocator, input: []const u8, stream: bool, referenced: bool) ![]const u8 {
++ return std.fmt.allocPrint(a, "{{\"model\":\"m\",\"input\":{s},\"response_format\":\"pcm\",\"stream\":{}{s}}}", .{
++ try json.quote(a, input, .{}), stream, if (referenced) ",\"references\":" ++ reference else "",
++ });
++}
++
++fn serve(a: std.mem.Allocator, turns: *scripted.Turns, config: server.Config, body: []const u8) ![]const u8 {
++ var s: wire.Served = undefined;
++ try s.startWith(turns.engine(), config, "HTTP/1.1", body, 2000);
++ defer s.deinit();
++ _ = try s.readHead();
++ _ = try s.readRest();
++ s.route.join();
++ return a.dupe(u8, s.got.items);
++}
++
++fn bytes(a: std.mem.Allocator, text: []const u8) ![]const u32 {
++ const out = try a.alloc(u32, text.len);
++ for (text, out) |byte, *token| token.* = byte;
++ return out;
++}
++
++fn userText(a: std.mem.Allocator, tokens: []const u32, text: []const u8) !void {
++ const end = try bytes(a, try std.mem.concat(a, u8, &.{ "<|im_start|>user\n", text, suffix }));
++ try std.testing.expect(std.mem.endsWith(u32, tokens, end));
++}
++
++fn contains(a: std.mem.Allocator, tokens: []const u32, text: []const u8) !void {
++ try std.testing.expect(std.mem.indexOf(u32, tokens, try bytes(a, text)) != null);
++}
++
++test "speakers: every canonical ID reaches the engine without changing dialogue text" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ for (0..16) |id| {
++ const input = try std.fmt.allocPrint(a, "\u{3000}<|speaker:{d}|> First. <|speaker:0|> Reply.", .{id});
++ var turns: scripted.Turns = .{ .turns = &.{.{ .frames = 2 }} };
++ defer turns.deinit();
++ const got = try serve(a, &turns, scripted.config(null), try request(a, input, false, false));
++ try std.testing.expect(std.mem.startsWith(u8, got, "HTTP/1.1 200 "));
++ try std.testing.expectEqual(@as(usize, 1), turns.seen);
++ try std.testing.expectEqualSlices(u32, try bytes(a, try std.mem.concat(a, u8, &.{ plain_head, input, suffix })), turns.prompts.items[0]);
++ }
++}
++
++test "speakers: referenced whole and streamed dialogue keeps reference and guidance identities" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ const input = "<|speaker:15|> First. <|speaker:1|> Reply. <|speaker:15|> Done.";
++ var config = scripted.config(null);
++ config.cfg = .{ .gamma = 3 };
++ for ([_]bool{ false, true }) |stream| {
++ var turns: scripted.Turns = .{ .turns = &.{.{ .frames = 2 }} };
++ defer turns.deinit();
++ const got = try serve(a, &turns, config, try request(a, input, stream, true));
++ try std.testing.expect(std.mem.startsWith(u8, got, "HTTP/1.1 200 "));
++ try std.testing.expectEqual(@as(usize, 1), turns.seen);
++ try userText(a, turns.prompts.items[0], input);
++ try contains(a, turns.prompts.items[0], "Text: <|speaker:15|> Reference.\nSpeech: ");
++ try std.testing.expectEqualSlices(u32, try bytes(a, plain_head ++ input ++ suffix), turns.unconditioned.items[0]);
++ var found: usize = 0;
++ for (turns.codes.items[0]) |codes| if (codes[0] != 0) {
++ try std.testing.expectEqualSlices(u32, &.{ 7, 1, 2, 3, 4, 5, 6, 7, 8, 9 }, &codes);
++ found += 1;
++ };
++ try std.testing.expectEqual(@as(usize, 1), found);
++ if (stream) try std.testing.expect(std.mem.endsWith(u8, got, "\r\n0\r\n\r\n"));
++ }
++}
++
++test "speakers: long-form continuations carry the active speaker with and without history or streaming" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ const input = "<|speaker:15|> First sentence. Second sentence. <|speaker:2|> Third sentence. Fourth sentence.";
++ const spoken = [_][]const u8{
++ "<|speaker:15|> First sentence.", "<|speaker:15|> Second sentence.",
++ "<|speaker:2|> Third sentence.", "<|speaker:2|> Fourth sentence.",
++ };
++ for ([_]u8{ 0, 1 }) |history| for ([_]bool{ false, true }) |stream| {
++ var turns: scripted.Turns = .{ .turns = &.{ .{ .frames = 2 }, .{ .frames = 2 }, .{ .frames = 2 }, .{ .frames = 2 } } };
++ defer turns.deinit();
++ var config = scripted.config(.{ .chars = 30, .history = history });
++ config.cfg = .{ .gamma = 3 };
++ const got = try serve(a, &turns, config, try request(a, input, stream, false));
++ try std.testing.expect(std.mem.startsWith(u8, got, "HTTP/1.1 200 "));
++ try std.testing.expectEqual(@as(usize, 4), turns.seen);
++ for (spoken, 0..) |text, i| {
++ try userText(a, turns.prompts.items[i], text);
++ if (history > 0 and i > 0) {
++ try contains(a, turns.prompts.items[i], try std.mem.concat(a, u8, &.{ "[Reference 1]\nText: ", spoken[i - 1], "\nSpeech: " }));
++ try std.testing.expectEqualSlices(u32, try bytes(a, try std.mem.concat(a, u8, &.{ plain_head, text, suffix })), turns.unconditioned.items[i]);
++ var found: usize = 0;
++ for (turns.codes.items[i]) |codes| if (codes[0] != 0) {
++ const want: prompt.Codes = @splat(@intCast(i * 100 + found));
++ try std.testing.expectEqualSlices(u32, &want, &codes);
++ found += 1;
++ };
++ try std.testing.expectEqual(@as(usize, 2), found);
++ } else try std.testing.expectEqual(@as(usize, 0), turns.unconditioned.items[i].len);
++ }
++ };
++}
++
++test "speakers: paragraph shifts and plain redraws preserve speaker tags in the next segment's history" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ const input = "<|speaker:15|> First sentence.\u{b6} [pause] Second sentence.\u{b6} Third sentence.";
++ for ([_]@import("speech_redraw.zig").Scope{ .cues, .controls }) |scope| {
++ var turns: scripted.Turns = .{ .turns = &.{ .{ .frames = 2 }, .{ .frames = 2, .reason = .silence }, .{ .frames = 2 }, .{ .frames = 2 } } };
++ defer turns.deinit();
++ var config = scripted.config(.{ .chars = 80, .history = 1, .shift_pause_s = 0.5 });
++ config.cfg = .{ .gamma = 3 };
++ config.silence_redraw = .{ .max = 1, .plain = true, .plain_scope = scope };
++ const got = try serve(a, &turns, config, try request(a, input, false, false));
++ try std.testing.expect(std.mem.startsWith(u8, got, "HTTP/1.1 200 "));
++ try std.testing.expectEqual(@as(usize, 4), turns.seen);
++ try userText(a, turns.prompts.items[1], "<|speaker:15|> [pause] Second sentence.");
++ try userText(a, turns.prompts.items[2], "<|speaker:15|> Second sentence.");
++ try userText(a, turns.prompts.items[3], "<|speaker:15|> Third sentence.");
++ try contains(a, turns.prompts.items[3], "Text: <|speaker:15|> Second sentence.\nSpeech: ");
++ try std.testing.expectEqualSlices(u32, try bytes(a, plain_head ++ "<|speaker:15|> Second sentence." ++ suffix), turns.unconditioned.items[2]);
++ try std.testing.expect(std.mem.indexOf(u8, got, "X-Pause-Spans: ") != null);
++ }
++}
++
++test "speakers: long-form admission counts the carried speaker tag before submitting any segment" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ const continuation = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.";
++ var turns: scripted.Turns = .{
++ .turns = &.{ .{ .frames = 2 }, .{ .frames = 2 } },
++ .max_prompt = plain_head.len + "<|speaker:0|> ".len + continuation.len + suffix.len,
++ };
++ defer turns.deinit();
++ const got = try serve(a, &turns, scripted.config(.{ .chars = 40 }), try request(a, "<|speaker:15|> Hi. " ++ continuation, false, false));
++ try std.testing.expect(std.mem.startsWith(u8, got, "HTTP/1.1 400 "));
++ try std.testing.expect(std.mem.indexOf(u8, got, "rows exceeds the maximum allowed length") != null);
++ try std.testing.expectEqual(@as(usize, 0), turns.seen);
++}
++
++test "speakers: protected hard cuts retain embedded tags and sampling controls through continuation" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ const input = "abcdefghijklmnop<|speaker:15|>abcdefghijklmnopqrstuvwxyz";
++ const spoken = [_][]const u8{
++ "<|speaker:0|> abcdefghijklmnop",
++ "<|speaker:15|>abcdef",
++ "<|speaker:15|> ghijklmnopqrstuvwxyz",
++ };
++ for ([_]bool{ false, true }) |stream| {
++ var turns: scripted.Turns = .{ .turns = &.{ .{ .frames = 2 }, .{ .frames = 2 }, .{ .frames = 2 } } };
++ defer turns.deinit();
++ var config = scripted.config(.{ .chars = 20, .history = 1 });
++ config.cfg = .{ .gamma = 2 };
++ const raw = try request(a, input, stream, false);
++ const body = try std.fmt.allocPrint(a, "{s},\"temperature\":0.5,\"top_p\":0.75,\"top_k\":8,\"seed\":9007199254740993}}", .{raw[0 .. raw.len - 1]});
++ const got = try serve(a, &turns, config, body);
++ try std.testing.expect(std.mem.startsWith(u8, got, "HTTP/1.1 200 "));
++ try std.testing.expectEqual(spoken.len, turns.seen);
++ for (spoken, 0..) |text, i| {
++ try userText(a, turns.prompts.items[i], text);
++ try std.testing.expectEqualDeep(scripted.Sampling{ .temperature = 0.5, .top_p = 0.75, .top_k = 8 }, turns.sampling.items[i]);
++ try std.testing.expectEqual(@as(u64, 9007199254740993), turns.seeds.items[i]);
++ if (i > 0) try userText(a, turns.unconditioned.items[i], text);
++ }
++ }
++}
++
++test "speakers: malformed dialogue is refused before submission with or without references" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ const bad = [_][]const u8{
++ "<|speaker:15|> Valid. <|speaker:16|> Bad.",
++ "<|speaker:1|> Valid. <|speaker:01|> Bad.",
++ "<|speaker:-1|> Bad.",
++ "<|speaker:\u{661}|> Bad.",
++ "Partial <|speaker:",
++ };
++ for ([_]bool{ false, true }) |referenced| for (bad) |input| {
++ var turns: scripted.Turns = .{ .turns = &.{.{ .frames = 2 }} };
++ defer turns.deinit();
++ const got = try serve(a, &turns, scripted.config(null), try request(a, input, true, referenced));
++ try std.testing.expect(std.mem.startsWith(u8, got, "HTTP/1.1 400 "));
++ try std.testing.expect(std.mem.indexOf(u8, got, "N in 0..15 and no leading zero") != null);
++ try std.testing.expectEqual(@as(usize, 0), turns.seen);
++ };
++}
+diff --git a/zig/src/server/voice_cli.zig b/zig/src/server/voice_cli.zig
+index d3f3d676..781a7751 100644
+--- a/zig/src/server/voice_cli.zig
++++ b/zig/src/server/voice_cli.zig
+@@ -121,6 +121,13 @@ test "the voice file loads with the codes it was written with, and a transcript
+ try std.testing.expectEqualStrings("a40ff3d5900fb7698b8c865041347cb49eccedc8f93945f89629ad104aaecce4", doc.strField("audio_sha256").?);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "\"Wie spät ist es?\"") != null);
+ try std.testing.expect(std.mem.endsWith(u8, bytes, "]]}\n"));
+- try std.testing.expect(try voiceFile(a, "<|speaker:4|> hi", &codes, "RIFF", .{ .object = encoder }, &problem) == null);
+- try std.testing.expectEqualStrings("the voice file would not load: Drama 3 request: only speaker 0 is supported; got speaker tags [4]", problem);
++ for (0..16) |id| {
++ const text = try std.fmt.allocPrint(a, "\u{3000}<|speaker:{d}|> hi", .{id});
++ const tagged = (try voiceFile(a, text, &codes, "RIFF", .{ .object = encoder }, &problem)).?;
++ const ref = (try drama_voice.parseReference(a, tagged, &problem)).?;
++ try std.testing.expectEqualStrings(text, ref.text);
++ try std.testing.expectEqualSlices(drama_prompt.Codes, &codes, ref.frames);
++ }
++ try std.testing.expect(try voiceFile(a, "<|speaker:16|> hi", &codes, "RIFF", .{ .object = encoder }, &problem) == null);
++ try std.testing.expectStringStartsWith(problem, "the voice file would not load: Drama 3 request: text: speaker tags must be written");
+ }
+--
+2.46.0
+
diff --git a/engine/nvfp4/patches/README.md b/engine/nvfp4/patches/README.md
index 5242c7c..3abe3f2 100644
--- a/engine/nvfp4/patches/README.md
+++ b/engine/nvfp4/patches/README.md
@@ -8,18 +8,24 @@ selected patch exactly once, verifies its digest, then applies the series.
|---|---|---|
| `0001-protected-speech-chunks.patch` | Protected markup and CJK long-form boundaries; test fixture drain ordering | [TensorFold #5](https://github.com/plotarmordev/TensorFold/pull/5) |
| `0002-speech-sampling-controls.patch` | Request sampling through whole/streamed, reference, CFG and long-form redraw paths | [TensorFold #6](https://github.com/plotarmordev/TensorFold/pull/6) |
+| `0003-dialogue-speakers.patch` | Canonical speaker IDs and continuation across long-form splits/history/redraws | [TensorFold #7](https://github.com/plotarmordev/TensorFold/pull/7) |
Patch 0002 adds [request sampling](../../../recipe/README-sampling.md). Each
long-form segment starts with the supplied seed; its retries add their own
attempt number. Omitted seeds retain the existing per-segment prompt hash.
+Patch 0003 adds [speaker-tagged dialogue](../../../recipe/README-dialogue.md)
+on top of patches 0001 and 0002. Protected chunk boundaries keep markers intact;
+each continuation receives the speaker active at its start. Combined regressions
+exercise a hard cut inside long words with explicit sampling and an exact seed.
+
Protected constructs are complete model special tokens, balanced bracketed
spans and correctly nested, exact-name ASCII tags without attributes. Unclosed
or mismatched markup does not protect unrelated following text. Adjacent
controls remain separate atoms. Segment slices retain their original backing
buffer, and only a whole protected atom may exceed the code-point target.
-CPU validation: 364 passed, four skipped; goldens have zero unexpected
+Patch 0001 CPU validation: 364 passed, four skipped; goldens have zero unexpected
differences. Independent adversarial testing exercised 1,680 combinations of
markup, Unicode and character targets. Six existing socket-fixture ordering
repairs let the macOS CPU suite drain replies before joining server threads;
@@ -27,6 +33,9 @@ production transport is unchanged. CUDA host compilation is still blocked on
macOS by the base's `O.DIRECT` use. No device/model or listening qualification
is claimed by these CPU checks.
+The composed three-patch CPU suite passes 379 tests with four skipped, including
+the combined chunking, speaker-continuation and explicit-sampling regression.
+
The [public MLX chunker](https://github.com/Sogni-AI/drama3-mlx/blob/ecc235039ccd5f982f66a6982184f57432354ab4/drama3_mlx/chunking.py)
is a behavior reference. This is an independent native implementation under
the recipe's Apache-2.0 license; model weights keep their separate license.
diff --git a/engine/nvfp4/patches/SHA256SUMS b/engine/nvfp4/patches/SHA256SUMS
index c27c658..820cd70 100644
--- a/engine/nvfp4/patches/SHA256SUMS
+++ b/engine/nvfp4/patches/SHA256SUMS
@@ -1,2 +1,3 @@
c0553b2cfd231ddbbe6390edfe49546db5bef084d5bb6a5009542ccccfdd2fb9 0001-protected-speech-chunks.patch
34e0110f8dad8382a8b592aa7d2e63fc9c2e472b6f3528cf555f770eb5bfeb25 0002-speech-sampling-controls.patch
+f7c3c79956b3c433a6de33889ba36f947b7bb7a0e3ba7ddfbc0695eb92ec3be0 0003-dialogue-speakers.patch
diff --git a/engine/patches/0015-dialogue-speakers.patch b/engine/patches/0015-dialogue-speakers.patch
new file mode 100644
index 0000000..a1b253b
--- /dev/null
+++ b/engine/patches/0015-dialogue-speakers.patch
@@ -0,0 +1,341 @@
+From c8463349418ddc53f6746c442db40a294b37fc0b Mon Sep 17 00:00:00 2001
+From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
+Date: Sun, 25 Oct 2026 11:47:33 +0800
+Subject: [PATCH] server: accept dialogue speaker tags zero through fifteen
+
+---
+ CHANGELOG.md | 2 +
+ README.md | 2 +-
+ docs/drama-http-voices.md | 22 +++-
+ zig/src/families/drama3/prompt.zig | 29 ++++++
+ zig/src/server/drama_prompt.zig | 40 ++-----
+ zig/src/server/root.zig | 1 +
+ zig/src/server/speech_speakers_test.zig | 132 ++++++++++++++++++++++++
+ 7 files changed, 196 insertions(+), 32 deletions(-)
+ create mode 100644 zig/src/server/speech_speakers_test.zig
+
+diff --git a/CHANGELOG.md b/CHANGELOG.md
+index f75699c3..32366bc1 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 accepts dialogue tags `<|speaker:0|>` through `<|speaker:15|>` in whole and streamed HTTP requests and catalog reference transcripts. Untagged text still uses speaker 0, and catalogs still allow at most four reference passages. See [dialogue speakers](docs/drama-http-voices.md#dialogue-speakers).
++
+ - 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.
+diff --git a/README.md b/README.md
+index ad7633de..6ef24f11 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` with [sampling controls](docs/speech-sampling.md); 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), [reference voices and dialogue speakers](docs/drama-http-voices.md). `/v1/audio/speech` with [sampling controls](docs/speech-sampling.md); checkpoint and `codec.pth` required. |
+
+ ### GLM-5.3: one machine or two?
+
+diff --git a/docs/drama-http-voices.md b/docs/drama-http-voices.md
+index 785479df..b0b052ee 100644
+--- a/docs/drama-http-voices.md
++++ b/docs/drama-http-voices.md
+@@ -20,4 +20,24 @@ The mandatory `source.kind` is `synthetic` (generated audio), `self` (the operat
+
+ 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.
++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, malformed 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.
++
++## Dialogue speakers
++
++Speech input and reference transcripts accept `<|speaker:N|>` with decimal IDs from `0` through `15`. Tags can change speakers within one request. Untagged text defaults to speaker 0; a leading tag after Unicode whitespace is preserved. Leading zeros, negative IDs, non-ASCII digits, incomplete tags and IDs above 15 are rejected.
++
++For example, send this body to `POST /v1/audio/speech` with your served model name:
++
++```json
++{
++ "model": "MODEL",
++ "input": "<|speaker:0|> Is the gate open? <|speaker:15|> Yes, come through. <|speaker:0|> Thank you.",
++ "response_format": "wav"
++}
++```
++
++For streamed audio, set `"stream": true` and `"response_format": "pcm"`. To condition speakers on reference audio, create a catalog entry whose reference transcripts have matching tags, such as `<|speaker:0|> First reference transcript.` and `<|speaker:15|> Second reference transcript.`, then select its name with `"voice": "conversation"`. The HTTP `speaker` field remains an alias for that catalog name; dialogue speaker IDs belong in the text tags.
++
++One catalog entry still accepts at most **four reference passages**, even though there are sixteen valid speaker IDs. Each passage keeps its own transcript and code rows. Reusing a catalog entry preserves its exact reference prefix; changing a reference speaker tag changes that prefix. Speaker tags specify the prompt's roles; this does not guarantee distinguishable voices without suitable references or establish voice similarity.
++
++The public [Drama 3 MLX prompt implementation](https://github.com/Sogni-AI/drama3-mlx/blob/ecc235039ccd5f982f66a6982184f57432354ab4/drama3_mlx/prompt.py) provides a behavior reference for these speaker markers. TensorFold uses its existing native prompt builder.
+diff --git a/zig/src/families/drama3/prompt.zig b/zig/src/families/drama3/prompt.zig
+index e9e40b6c..0f069788 100644
+--- a/zig/src/families/drama3/prompt.zig
++++ b/zig/src/families/drama3/prompt.zig
+@@ -226,3 +226,32 @@ test "reference cache: last reference code and equal-length changed tower lookah
+ first.frames[saved_rows][0] += 1;
+ try t.expect(!try prefix.begin(t.allocator, first.frames, saved_rows));
+ }
++
++test "reference cache: a reference speaker change misses while request speaker changes reuse the prefix" {
++ const t = std.testing;
++ const cache = @import("reference_cache.zig");
++ var prefix: cache.Prefix = .{};
++ defer prefix.deinit(t.allocator);
++ const codes = [_][cfg.codebooks]i32{@splat(1)};
++ var refs = [_]Reference{
++ .{ .transcript = "<|speaker:1|> First.", .codes = &codes },
++ .{ .transcript = "<|speaker:15|> Second.", .codes = &codes },
++ };
++ const first = try build(t.allocator, ByteTokenizer{}, "<|speaker:1|> 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();
++
++ const request = try build(t.allocator, ByteTokenizer{}, "<|speaker:15|> Two. <|speaker:1|> Three.", &refs);
++ defer t.allocator.free(request.frames);
++ try t.expectEqual(first.reference_rows, request.reference_rows);
++ try t.expect(try prefix.begin(t.allocator, request.frames, saved_rows));
++
++ refs[0].transcript = "<|speaker:2|> First.";
++ const changed = try build(t.allocator, ByteTokenizer{}, "<|speaker:1|> One.", &refs);
++ defer t.allocator.free(changed.frames);
++ try t.expectEqual(first.frames.len, changed.frames.len);
++ try t.expectEqual(first.reference_rows, changed.reference_rows);
++ try t.expect(!try prefix.begin(t.allocator, changed.frames, saved_rows));
++}
+diff --git a/zig/src/server/drama_prompt.zig b/zig/src/server/drama_prompt.zig
+index 3e466c86..ea526873 100644
+--- a/zig/src/server/drama_prompt.zig
++++ b/zig/src/server/drama_prompt.zig
+@@ -63,36 +63,13 @@ fn speakerId(digits: []const u8) ?u8 {
+ return if (n <= max_speaker_id) n else null;
+ }
+
+-/// Why input cannot be spoken, in the reference's words and order, or null.
++/// Why input cannot be spoken; canonical speaker tags 0..15 are accepted in requests and references.
+ pub fn problem(a: Allocator, input: []const u8) Allocator.Error!?[]const u8 {
+- // speech_request.py: well-formed tags naming a speaker other than 0 (malformed ones are the builder's)
+- var others: u16 = 0;
+- var at: usize = 0;
+- while (std.mem.indexOfPos(u8, input, at, "<|speaker:")) |start| {
+- if (tagAt(input, start)) |tag| {
+- if (speakerId(tag.digits)) |n| if (n != 0) {
+- others |= @as(u16, 1) << @intCast(n);
+- };
+- at = tag.end;
+- } else at = start + 1;
+- }
+- if (others != 0) {
+- var list: std.ArrayList(u8) = .empty;
+- try list.appendSlice(a, error_prefix ++ " only speaker 0 is supported; got speaker tags [");
+- var first = true;
+- for (1..max_speaker_id + 1) |n| if (others & (@as(u16, 1) << @intCast(n)) != 0) {
+- if (!first) try list.appendSlice(a, ", ");
+- try list.print(a, "{d}", .{n});
+- first = false;
+- };
+- try list.append(a, ']');
+- return list.items;
+- }
+ // prompt.py's validate_caller_text(text, "input")
+ if (trimStart(input).len == 0) return error_prefix ++ " input: text must not be empty";
+ for (structural_tokens) |token| if (std.mem.indexOf(u8, input, token) != null)
+ return try std.fmt.allocPrint(a, error_prefix ++ " input: {s} is written by the prompt builder and must not appear in caller text", .{token});
+- at = 0;
++ var at: usize = 0;
+ while (std.mem.indexOfPos(u8, input, at, "<|speaker")) |start| {
+ const tag = tagAt(input, start);
+ if (tag == null or speakerId(tag.?.digits) == null)
+@@ -137,6 +114,7 @@ test "speaker 0 tags untagged text; a leading tag after white space is kept" {
+ .{ "\n<|speaker:0|> hi", "\n<|speaker:0|> hi" },
+ .{ "\u{3000}<|speaker:0|> hi", "\u{3000}<|speaker:0|> hi" },
+ .{ "a <|speaker:0|> b", "<|speaker:0|> a <|speaker:0|> b" },
++ .{ "a <|speaker:15|> b", "<|speaker:0|> a <|speaker:15|> b" },
+ .{ "\x1c<|speaker:0|> hi", "<|speaker:0|> \x1c<|speaker:0|> hi" },
+ .{ "\x1f", "<|speaker:0|> \x1f" },
+ };
+@@ -144,20 +122,22 @@ test "speaker 0 tags untagged text; a leading tag after white space is kept" {
+ try std.testing.expect(try problem(a, c[0]) == null);
+ try std.testing.expectEqualStrings(c[1], try tagged(a, c[0]));
+ }
++ for (0..16) |id| {
++ const dialogue = try std.fmt.allocPrint(a, "\u{3000}<|speaker:{d}|> First. <|speaker:0|> Reply.", .{id});
++ try std.testing.expect(try problem(a, dialogue) == null);
++ try std.testing.expectEqualStrings(dialogue, try tagged(a, dialogue));
++ }
+ }
+
+-test "input the reference refuses" {
++test "empty input, malformed speaker tags and structural tokens are refused" {
+ var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena.deinit();
+ const a = arena.allocator();
+- const malformed = [_][]const u8{ "<|speaker:99|> x", "<|speaker:16|> x", "a <|speaker:99|> b", "<|speaker:01|> x", "<|speaker:00|> x", "<|speaker:|> x", "<|speaker0|> x", "hi <|speaker", "<|speaker:abc|> x", "<|speaker:0> x", " <|speaker:abc|> hi" };
++ const malformed = [_][]const u8{ "<|speaker:99|> x", "<|speaker:16|> x", "a <|speaker:99|> b", "<|speaker:01|> x", "<|speaker:00|> x", "<|speaker:|> x", "<|speaker0|> x", "hi <|speaker", "<|speaker:abc|> x", "<|speaker:0> x", " <|speaker:abc|> hi", "<|speaker:2|> x <|speaker:01|>" };
+ for (malformed) |text| {
+ const message = (try problem(a, text)) orelse return error.TestUnexpectedResult;
+ try std.testing.expect(std.mem.startsWith(u8, message, "Drama 3 request: input: speaker tags must be written <|speaker:N|> with N in 0..15 and no leading zero; got "));
+ }
+- try std.testing.expectEqualStrings("Drama 3 request: only speaker 0 is supported; got speaker tags [1, 15]", (try problem(a, "<|speaker:15|> a <|speaker:1|> b <|speaker:1|> c <|speaker:0|> d")).?);
+- // other speakers are refused before malformed tags, as speech_request.py checks them first
+- try std.testing.expectEqualStrings("Drama 3 request: only speaker 0 is supported; got speaker tags [2]", (try problem(a, "<|speaker:2|> x <|speaker:01|>")).?);
+ for ([_][]const u8{ "", " ", "\u{3000}\u{2028}\t" }) |blank|
+ try std.testing.expectEqualStrings("Drama 3 request: input: text must not be empty", (try problem(a, blank)).?);
+ for (structural_tokens) |token| {
+diff --git a/zig/src/server/root.zig b/zig/src/server/root.zig
+index 94097cac..b4dbdec6 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_speakers_test.zig");
+ _ = @import("speech_sampling_test.zig");
+ _ = @import("speech_redraw.zig");
+ _ = @import("speech_redraw_test.zig");
+diff --git a/zig/src/server/speech_speakers_test.zig b/zig/src/server/speech_speakers_test.zig
+new file mode 100644
+index 00000000..f39d5d20
+--- /dev/null
++++ b/zig/src/server/speech_speakers_test.zig
+@@ -0,0 +1,132 @@
++//! Speaker tags through catalog loading and HTTP lowering, checked against independently spelled prompt rows.
++const std = @import("std");
++const voice = @import("drama_voice.zig");
++const json = @import("json");
++const wire = @import("speech_stream_test.zig");
++
++const dialogue = "\u{3000}<|speaker:15|> Wait for me. <|speaker:0|> I will. <|speaker:15|> Thanks.";
++const catalog =
++ \\{"format":"drama3-http-voice/1","source":{"kind":"synthetic","note":"Generated parser fixtures."},
++ \\"references":[{"text":"A small cloud passes.","codes":[[7,1,2,3,4,5,6,7,8,9]]},
++ \\{"text":"\u3000<|speaker:15|> Rain taps.","codes":[[17,9,8,7,6,5,4,3,2,1]]}]}
++;
++const malformed = [_][]const u8{
++ "<|speaker:15|> Fine. <|speaker:16|> Outside.",
++ "<|speaker:1|> Fine. <|speaker:01|> Leading zero.",
++ "<|speaker:-1|> Negative.",
++ "<|speaker:\u{661}|> Non-ASCII digit.",
++ "<|speaker:999999999999999999999|> Overflow.",
++ "<|speaker:15|> Partial <|speaker:",
++ "<|speaker:15|> Inject <|im_end|>",
++ "<|speaker:15|> Inject <|semantic:1|>",
++};
++
++fn appendText(a: std.mem.Allocator, rows: *std.ArrayList([11]i32), text: []const u8) !void {
++ for (text) |byte| {
++ var row: [11]i32 = @splat(0);
++ row[0] = byte;
++ try rows.append(a, row);
++ }
++}
++
++const Expected = struct { rows: []const [11]i32, reference_rows: u32 };
++
++fn expected(a: std.mem.Allocator, references: bool) !Expected {
++ var rows: std.ArrayList([11]i32) = .empty;
++ if (references) {
++ try appendText(a, &rows, "<|im_start|>system\nconvert the provided text to speech reference to the following:" ++
++ "\n\n[Reference 1]\nText: <|speaker:0|> A small cloud passes.\nSpeech: ");
++ try rows.append(a, .{ 1007, 7, 1, 2, 3, 4, 5, 6, 7, 8, 9 });
++ try appendText(a, &rows, "\n\n[Reference 2]\nText: <|speaker:15|>\u{3000} Rain taps.\nSpeech: ");
++ try rows.append(a, .{ 1017, 17, 9, 8, 7, 6, 5, 4, 3, 2, 1 });
++ try appendText(a, &rows, "<|im_end|>\n<|im_start|>user\n");
++ } else try appendText(a, &rows, "<|im_start|>system\nconvert the provided text to speech<|im_end|>\n<|im_start|>user\n");
++ const boundary: u32 = if (references) @intCast(rows.items.len) else 0;
++ try appendText(a, &rows, dialogue ++ "<|im_end|>\n<|im_start|>assistant\n<|voice|>");
++ return .{ .rows = rows.items, .reference_rows = boundary };
++}
++
++test "speakers: whole and streamed HTTP preserve dialogue, catalog tags and exact code rows" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ for ([_]bool{ false, true }) |references| {
++ const voices = [_]voice.Voice{.{ .name = "duo", .references = if (references) try voice.parse(a, catalog) else &.{} }};
++ const want = try expected(a, references);
++ for ([_]bool{ false, true }) |stream| {
++ var script: wire.Script = .{ .steps = &.{wire.frame}, .voices = &voices, .expected_rows = want.rows, .inline_ = true };
++ const request = try std.json.Stringify.valueAlloc(a, .{
++ .model = "m",
++ .input = dialogue,
++ .voice = if (references) "duo" else "default",
++ .response_format = "pcm",
++ .stream = stream,
++ }, .{});
++ var s: wire.Served = undefined;
++ try s.open(&script, .{ .served_name = "m", .model_ids = &.{"m"} }, "", "HTTP/1.1", request, 2000);
++ defer s.deinit();
++ const head = try a.dupe(u8, try s.readHead());
++ const rest = try s.readRest();
++ s.join(&script);
++ try wire.expectHeader(head, " 200 ");
++ try std.testing.expectEqual(@as(usize, 1), script.takes);
++ try std.testing.expect(script.rows_equal);
++ try std.testing.expectEqual(want.reference_rows, script.reference_rows[0]);
++ const pcm = if (stream) blk: {
++ try wire.expectHeader(head, "Transfer-Encoding: chunked\r\n");
++ try std.testing.expect(std.mem.startsWith(u8, rest, "1000\r\n"));
++ try std.testing.expect(std.mem.endsWith(u8, rest, "\r\n0\r\n\r\n"));
++ break :blk rest["1000\r\n".len .. rest.len - "\r\n0\r\n\r\n".len];
++ } else blk: {
++ try wire.expectHeader(head, "Content-Length: 4096\r\n");
++ break :blk rest;
++ };
++ try std.testing.expectEqual(@as(usize, wire.frame * 2), pcm.len);
++ for (pcm, 0..) |byte, i| try std.testing.expectEqual(@as(u8, if (i % 2 == 0) 0xff else 0x3f), byte);
++ }
++ }
++}
++
++test "speakers: catalog accepts every canonical ID and refuses malformed or structural tags" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ const doc = (try json.parse(a, catalog)).ok;
++ const reference = doc.get("references").?.array[1].object;
++ for (0..16) |id| {
++ const text = try std.fmt.allocPrint(a, "\u{3000}<|speaker:{d}|> Rain taps.", .{id});
++ try reference.put(a, "text", .{ .string = text });
++ const refs = try voice.parse(a, try json.stringify(a, doc, .{}));
++ try std.testing.expectEqualStrings(text, refs[1].transcript);
++ try std.testing.expectEqualSlices(i32, &.{ 17, 9, 8, 7, 6, 5, 4, 3, 2, 1 }, &refs[1].codes[0]);
++ }
++ for (malformed) |text| {
++ try reference.put(a, "text", .{ .string = text });
++ try std.testing.expectError(error.BadVoice, voice.parse(a, try json.stringify(a, doc, .{})));
++ }
++}
++
++test "speakers: malformed dialogue refuses before engine submission with default or catalog voice" {
++ var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
++ defer arena.deinit();
++ const a = arena.allocator();
++ const voices = [_]voice.Voice{.{ .name = "duo", .references = try voice.parse(a, catalog) }};
++ for ([_][]const u8{ "default", "duo" }) |name| for (malformed) |input| {
++ var script: wire.Script = .{ .steps = &.{wire.frame}, .voices = &voices, .inline_ = true };
++ const request = try std.json.Stringify.valueAlloc(a, .{
++ .model = "m",
++ .input = input,
++ .voice = name,
++ .response_format = "pcm",
++ .stream = true,
++ }, .{});
++ var s: wire.Served = undefined;
++ try s.open(&script, .{ .served_name = "m", .model_ids = &.{"m"} }, "", "HTTP/1.1", request, 2000);
++ defer s.deinit();
++ const head = try a.dupe(u8, try s.readHead());
++ _ = try s.readRest();
++ s.join(&script);
++ try wire.expectHeader(head, " 400 ");
++ try std.testing.expectEqual(@as(usize, 0), script.takes);
++ };
++}
+--
+2.46.0
+
diff --git a/engine/patches/README.md b/engine/patches/README.md
index fb717c8..4943423 100644
--- a/engine/patches/README.md
+++ b/engine/patches/README.md
@@ -18,6 +18,11 @@ Full-model patches 0013/0014 add [request sampling controls](../../recipe/README
and document the existing JSON floating-point boundary. They backport
[TensorFold #1277](https://github.com/ashhart/TensorFold/pull/1277) without moving the pin.
+Full-model patch 0015 adds [speaker-tagged dialogue](../../recipe/README-dialogue.md)
+from [TensorFold #1278](https://github.com/ashhart/TensorFold/pull/1278).
+The full pin predates the native voice writer; its Python catalog writer accepts
+the same canonical speaker IDs without adding an engine tool absent from that 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.
@@ -38,6 +43,7 @@ It does not change the full-model series above or the CUDA NVFP4 pin.
| [`0003-shared-cli-extension-point.patch`](../nvfp4/macos/patches/0003-shared-cli-extension-point.patch) | Group CFG flags beside voice selection without occupying shared CLI-table extension points; no parsing or numerical change | [TensorFold #1268](https://github.com/ashhart/TensorFold/pull/1268) |
| [`0004-speech-sampling-controls.patch`](../nvfp4/macos/patches/0004-speech-sampling-controls.patch) | Request sampling parameters and exact seeds, retaining CFG rows | [TensorFold #1277](https://github.com/ashhart/TensorFold/pull/1277) |
| [`0005-sampling-json-boundaries.patch`](../nvfp4/macos/patches/0005-sampling-json-boundaries.patch) | Parsed-JSON floating boundary documentation and regressions | [TensorFold #1277](https://github.com/ashhart/TensorFold/pull/1277) |
+| [`0006-dialogue-speakers.patch`](../nvfp4/macos/patches/0006-dialogue-speakers.patch) | Canonical speaker IDs in request/catalog transcripts and the native writer | [TensorFold #1278](https://github.com/ashhart/TensorFold/pull/1278) |
The [Mac qualification receipt](../../results/macos-nvfp4.json) separates observed GPU/model results,
the initial failed fixture and focused correction, and the final CPU-only CLI integration build.
diff --git a/engine/patches/SHA256SUMS b/engine/patches/SHA256SUMS
index fe92a6c..d41c6c0 100644
--- a/engine/patches/SHA256SUMS
+++ b/engine/patches/SHA256SUMS
@@ -12,3 +12,4 @@ db1a8e4bdf817ab7d92e4635453075d8ea31a0d40537dcda8ccc9bec456a69cf 0010-feat-spee
562b63cbae92fe004ad1ab07ea791e1f3b212c2d7d71df40898257df7ad829f8 0012-feat-drama3-reuse-a-voice-reference-s-prefill-across.patch
b686340bc1fc0bccabdf6089bcea697ac4fee7741829fb6ffb49b3d0f15b9ab7 0013-speech-sampling-controls.patch
3e0855a714dc5b705bed956937a9d0eac272f76a0d03021243e7aab5d1c8f753 0014-sampling-json-boundaries.patch
+fcfc191174ed1621234fad3c15fc6ddfea784b57c04d53f0c568d1fd6f0e656e 0015-dialogue-speakers.patch
diff --git a/recipe/README-dialogue.md b/recipe/README-dialogue.md
new file mode 100644
index 0000000..1fe658b
--- /dev/null
+++ b/recipe/README-dialogue.md
@@ -0,0 +1,42 @@
+# Speaker-tagged dialogue
+
+The patched speech routes accept canonical tags `<|speaker:0|>` through
+`<|speaker:15|>` in request text and reference transcripts. Untagged requests
+still start as speaker 0. Tags are preserved in the model prompt:
+
+```json
+{
+ "model": "fishaudio/drama-3",
+ "input": "<|speaker:1|> Is the train here? <|speaker:15|> It arrives at seven fifteen.",
+ "voice": "dialogue",
+ "response_format": "pcm"
+}
+```
+
+The example assumes a catalog named `dialogue` is loaded. On the full-model and
+Metal NVFP4 profiles, label each encoded reference transcript with its matching
+speaker ID when building the catalog:
+
+```sh
+python3 recipe/build_voice.py --source synthetic dialogue 'Model-generated reference voices.' '<|speaker:1|> First reference transcript.:first.codes' '<|speaker:15|> Second reference transcript.:second.codes'
+```
+
+Use transcripts that actually match the reference clips. The CUDA NVFP4 profile
+keeps its existing encoder catalog and request-code-reference formats; their
+transcripts accept the same tags. No reference-audio upload route is added.
+
+The full-model and Metal NVFP4 catalogs, and the Python writer, retain their
+four-reference limit. Legacy CUDA keeps its existing body, file and prompt-size
+admission bounds; it has no fixed four-reference count cap.
+A tag selects a model speaker label; it is not a guarantee of a distinct or
+faithfully cloned voice. Listening and reference-conditioned device tests remain
+necessary to assess speaker identity.
+
+Malformed tags such as `<|speaker:01|>`, IDs outside 0..15, structural prompt
+tokens and the existing size/reference limits remain rejected. In the legacy
+CUDA long-form path, continuation segments retain the active speaker across
+automatic cuts, forced paragraph boundaries and redraws.
+
+The public [Sogni-AI/drama3-mlx prompt tests](https://github.com/Sogni-AI/drama3-mlx/blob/ecc235039ccd5f982f66a6982184f57432354ab4/tests/test_prompt.py#L38)
+provide the behavioral reference. These changes extend TensorFold's existing
+native prompt builder without copying Fish-derived Python implementation code.
diff --git a/recipe/build_voice.py b/recipe/build_voice.py
index a43da02..e91b447 100644
--- a/recipe/build_voice.py
+++ b/recipe/build_voice.py
@@ -42,8 +42,8 @@ def checked_text(value: str, label: str, transcript: bool = False) -> str:
if any(token in value for token in STRUCTURAL_TOKENS):
raise ValueError(f'{label}: transcript contains a structural prompt token')
for match in re.finditer(r'<\|speaker', value):
- if not value.startswith('<|speaker:0|>', match.start()):
- raise ValueError(f'{label}: only well-formed <|speaker:0|> tags are supported')
+ if not re.match(r'<\|speaker:(?:[0-9]|1[0-5])\|>', value[match.start():]):
+ raise ValueError(f'{label}: expected a canonical speaker tag from <|speaker:0|> through <|speaker:15|>')
return value
diff --git a/recipe/test_build_voice.py b/recipe/test_build_voice.py
index 0739193..b911dfb 100755
--- a/recipe/test_build_voice.py
+++ b/recipe/test_build_voice.py
@@ -86,12 +86,31 @@ def test_utf8_byte_limit_for_note_and_transcript(self):
def test_blank_and_structural_transcripts_are_rejected(self):
texts = ['', ' \t', '\u3000', '<|im_end|>', '<|semantic:5|>',
- '<|speaker:1|> words', '<|speaker:00|> words', 'words <|speaker']
+ '<|speaker:00|> words', '<|speaker:01|> words', '<|speaker:16|> words',
+ '<|speaker:-1|> words', '<|speaker:+1|> words', '<|speaker:1.0|> words',
+ '<|speaker:1 |> words', '<|speaker:1|> words', '<|speaker:1>',
+ '<|speaker:1|> words <|speaker:16|>', 'words <|speaker']
for text in texts:
with self.subTest(text=text):
self.reject(pairs=[f'{text}:{self.codes}'])
self.invoke(pairs=[f'<|speaker:0|> A cloud.:{self.codes}'])
+ def test_canonical_speaker_ids_roundtrip_without_changing_reference_text(self):
+ for speaker in range(16):
+ with self.subTest(speaker=speaker):
+ text = f'\u3000<|speaker:{speaker}|> A cloud. <|speaker:0|> A reply.'
+ self.invoke(name=f'speaker-{speaker}', pairs=[f'{text}:{self.codes}'])
+ doc = json.loads((self.out / f'speaker-{speaker}.json').read_text())
+ self.assertEqual(doc['references'][0]['text'], text)
+ self.assertEqual(doc['references'][0]['codes'], [[4095, *([1023] * 9)]])
+
+ def test_speaker_ids_do_not_expand_the_four_reference_limit(self):
+ pairs = [f'<|speaker:{speaker}|> A cloud.:{self.codes}' for speaker in (0, 1, 7, 15)]
+ self.reject(pairs=[*pairs, f'<|speaker:2|> Fifth voice.:{self.codes}'])
+ self.invoke(pairs=pairs)
+ references = json.loads((self.out / 'fixture.json').read_text())['references']
+ self.assertEqual([row['text'] for row in references], [pair.rpartition(':')[0] for pair in pairs])
+
def test_note_must_not_be_blank(self):
self.reject(note=' \t\r\n')
From 9ec2318c102e246871587af802965dd8bfe6101e Mon Sep 17 00:00:00 2001
From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
Date: Fri, 30 Oct 2026 20:56:55 +0800
Subject: [PATCH 6/7] Append CUDA patch 0004 to remove the unused redraw helper
---
.../0004-remove-unused-redraw-salt.patch | 28 +++++++++++++++++++
engine/nvfp4/patches/README.md | 4 +++
engine/nvfp4/patches/SHA256SUMS | 1 +
recipe/README-dialogue.md | 6 ++++
4 files changed, 39 insertions(+)
create mode 100644 engine/nvfp4/patches/0004-remove-unused-redraw-salt.patch
diff --git a/engine/nvfp4/patches/0004-remove-unused-redraw-salt.patch b/engine/nvfp4/patches/0004-remove-unused-redraw-salt.patch
new file mode 100644
index 0000000..6ac0f28
--- /dev/null
+++ b/engine/nvfp4/patches/0004-remove-unused-redraw-salt.patch
@@ -0,0 +1,28 @@
+From e06a0cda2fd9fa6adcb5a309581b7691173bd1ce Mon Sep 17 00:00:00 2001
+From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
+Date: Fri, 30 Oct 2026 19:36:07 +0800
+Subject: [PATCH] server: remove unused redraw seed helper
+
+---
+ zig/src/server/speech_redraw.zig | 5 -----
+ 1 file changed, 5 deletions(-)
+
+diff --git a/zig/src/server/speech_redraw.zig b/zig/src/server/speech_redraw.zig
+index fd34beac..e5c4df27 100644
+--- a/zig/src/server/speech_redraw.zig
++++ b/zig/src/server/speech_redraw.zig
+@@ -69,11 +69,6 @@ pub const Redraws = struct {
+ return std.fmt.bufPrint(buf, "{d}", .{r.hollow orelse return null}) catch unreachable;
+ }
+
+- /// The seed salt of a synthesis's attempt `k` (0 first, then its redraws): the server's salt plus `k`.
+- pub fn salt(base: i64, k: u8) i64 {
+- return base +% k;
+- }
+-
+ /// Whether a draw of `text` that ended `reason` with `samples` at `rate` trips: silence, or a short stop.
+ pub fn trip(r: *const Redraws, reason: api.Reason, samples: usize, rate: u32, text: []const u8) ?Trip {
+ if (reason == .silence) return .silence;
+--
+2.46.0
+
diff --git a/engine/nvfp4/patches/README.md b/engine/nvfp4/patches/README.md
index 3abe3f2..ae9ac26 100644
--- a/engine/nvfp4/patches/README.md
+++ b/engine/nvfp4/patches/README.md
@@ -9,6 +9,7 @@ selected patch exactly once, verifies its digest, then applies the series.
| `0001-protected-speech-chunks.patch` | Protected markup and CJK long-form boundaries; test fixture drain ordering | [TensorFold #5](https://github.com/plotarmordev/TensorFold/pull/5) |
| `0002-speech-sampling-controls.patch` | Request sampling through whole/streamed, reference, CFG and long-form redraw paths | [TensorFold #6](https://github.com/plotarmordev/TensorFold/pull/6) |
| `0003-dialogue-speakers.patch` | Canonical speaker IDs and continuation across long-form splits/history/redraws | [TensorFold #7](https://github.com/plotarmordev/TensorFold/pull/7) |
+| `0004-remove-unused-redraw-salt.patch` | Remove the unused seed-salt helper superseded by request sampling | [TensorFold #7](https://github.com/plotarmordev/TensorFold/pull/7) |
Patch 0002 adds [request sampling](../../../recipe/README-sampling.md). Each
long-form segment starts with the supplied seed; its retries add their own
@@ -19,6 +20,9 @@ on top of patches 0001 and 0002. Protected chunk boundaries keep markers intact;
each continuation receives the speaker active at its start. Combined regressions
exercise a hard cut inside long words with explicit sampling and an exact seed.
+Patch 0004 removes `Redraws.salt`, whose two callers moved to the request
+sampling implementation in patch 0002. It changes no live sampling path.
+
Protected constructs are complete model special tokens, balanced bracketed
spans and correctly nested, exact-name ASCII tags without attributes. Unclosed
or mismatched markup does not protect unrelated following text. Adjacent
diff --git a/engine/nvfp4/patches/SHA256SUMS b/engine/nvfp4/patches/SHA256SUMS
index 820cd70..fe53e59 100644
--- a/engine/nvfp4/patches/SHA256SUMS
+++ b/engine/nvfp4/patches/SHA256SUMS
@@ -1,3 +1,4 @@
c0553b2cfd231ddbbe6390edfe49546db5bef084d5bb6a5009542ccccfdd2fb9 0001-protected-speech-chunks.patch
34e0110f8dad8382a8b592aa7d2e63fc9c2e472b6f3528cf555f770eb5bfeb25 0002-speech-sampling-controls.patch
f7c3c79956b3c433a6de33889ba36f947b7bb7a0e3ba7ddfbc0695eb92ec3be0 0003-dialogue-speakers.patch
+b389f187b19eb81452a8ba1e9a18e843c06e7a458bb0c8db46725ae4a98a0539 0004-remove-unused-redraw-salt.patch
diff --git a/recipe/README-dialogue.md b/recipe/README-dialogue.md
index 1fe658b..47d1599 100644
--- a/recipe/README-dialogue.md
+++ b/recipe/README-dialogue.md
@@ -37,6 +37,12 @@ tokens and the existing size/reference limits remain rejected. In the legacy
CUDA long-form path, continuation segments retain the active speaker across
automatic cuts, forced paragraph boundaries and redraws.
+The legacy CUDA splitter keeps its existing character, segment, prompt and
+frame limits. It does not adopt the MLX chunker's policy of at most three
+distinct speakers per chunk. Accepting sixteen valid speaker IDs does not
+qualify the acoustic quality of sixteen speakers in a single chunk. The
+full-model and Metal profiles do not gain a long-form splitter from this patch.
+
The public [Sogni-AI/drama3-mlx prompt tests](https://github.com/Sogni-AI/drama3-mlx/blob/ecc235039ccd5f982f66a6982184f57432354ab4/tests/test_prompt.py#L38)
provide the behavioral reference. These changes extend TensorFold's existing
native prompt builder without copying Fish-derived Python implementation code.
From 6cd48e6584cc567bc365dcb488f808cea2c46e97 Mon Sep 17 00:00:00 2001
From: Mauvis Ledford <20738+krunkosaurus@users.noreply.github.com>
Date: Fri, 30 Oct 2026 21:26:22 +0800
Subject: [PATCH 7/7] Document the full-model dialogue stopping limitation
---
recipe/README-dialogue.md | 20 +
results/README.md | 1 +
results/dialogue-stopping-20261030.json | 481 ++++++++++++++++++++++++
3 files changed, 502 insertions(+)
create mode 100644 results/dialogue-stopping-20261030.json
diff --git a/recipe/README-dialogue.md b/recipe/README-dialogue.md
index 47d1599..5e31d9a 100644
--- a/recipe/README-dialogue.md
+++ b/recipe/README-dialogue.md
@@ -43,6 +43,26 @@ distinct speakers per chunk. Accepting sixteen valid speaker IDs does not
qualify the acoustic quality of sixteen speakers in a single chunk. The
full-model and Metal profiles do not gain a long-form splitter from this patch.
+## Known full-model stopping limitation
+
+On one GB10 with the pinned CUDA full-model profile, this page's two-speaker
+example reached the 1024-frame cap (47.55 seconds, `X-Finish-Reason: length`)
+in three of five seeds with a matching reference catalog. The same text without
+speaker tags stopped normally in all five. Relabeling both references and input
+to speakers 0/1 still reached the cap in two of five seeds; using the tagged
+input without a reference catalog did so in one of five. Neither variation is a
+reliable workaround.
+
+The CUDA NVFP4 profile, with its default guidance, stopped normally on all five
+tagged and all five untagged requests in this small check. The engine pins,
+weights and guidance differ, so these observations do not establish the cause
+or a guarantee for NVFP4. Metal was not tested in this follow-up. See the
+[settings and per-seed results](../results/dialogue-stopping-20261030.json).
+
+Treat `X-Finish-Reason: length` as an incomplete generation. The cause remains
+unresolved; accepting a speaker tag does not guarantee natural termination.
+The investigation is tracked in [issue #32](https://github.com/plotarmordev/drama-tensorfold/issues/32).
+
The public [Sogni-AI/drama3-mlx prompt tests](https://github.com/Sogni-AI/drama3-mlx/blob/ecc235039ccd5f982f66a6982184f57432354ab4/tests/test_prompt.py#L38)
provide the behavioral reference. These changes extend TensorFold's existing
native prompt builder without copying Fish-derived Python implementation code.
diff --git a/results/README.md b/results/README.md
index 5b5e3f2..c73cdbf 100644
--- a/results/README.md
+++ b/results/README.md
@@ -9,6 +9,7 @@ published; the JSON files retain the measured results and synthetic-input hashes
| File | What it holds |
|---|---|
+| [`dialogue-stopping-20261030.json`](dialogue-stopping-20261030.json) | GB10 CUDA speaker-tagged stopping diagnostic: full-model frame-cap failures, untagged controls, reference/label variants and NVFP4 comparison; no listening or quality claim |
| [`macos-nvfp4.json`](macos-nvfp4.json) | Native Metal NVFP4/CFG support on M5 Ultra: 28 HTTP checks, paired/separate PCM identity, registered operator gates with the initial fixture failure retained; no speed or quality-equivalence claim |
| [`macos-pr17-repair.json`](macos-pr17-repair.json) | Current full-model Metal cache/lifecycle repair: 48/48 paired corpus captures identical, transition/cancellation/multilane parity, bounded voice-switch RSS; no speed claim |
| [`before-after-02/`](before-after-02/README.md) | Reference against native: latency, RTF, first audio, cold start, frame-cap replies |
diff --git a/results/dialogue-stopping-20261030.json b/results/dialogue-stopping-20261030.json
new file mode 100644
index 0000000..e6912d6
--- /dev/null
+++ b/results/dialogue-stopping-20261030.json
@@ -0,0 +1,481 @@
+{
+ "date": "2026-10-30",
+ "purpose": "Bounded reproduction of PR #28 speaker-tagged stopping behavior; no speed or acoustic-quality claim.",
+ "recipe_commit": "9ec2318c102e246871587af802965dd8bfe6101e",
+ "hardware": {
+ "gpu": "NVIDIA GB10",
+ "os": "Ubuntu 24.04.5 LTS",
+ "driver": "580.178.04",
+ "cuda_compiler": "13.0.88",
+ "zig": "0.17.0"
+ },
+ "engine_sources": {
+ "full": {
+ "base": "d1018b318fb51981c4a18f12e40269a4d3ee9713",
+ "patches": 15,
+ "source_tree": "d7eb6817d37bac0807571b3c90e2c55b1c231a96",
+ "binary_sha256": "d9e507c4a22d1254fd90cdd57fc509fdbb004aa53e16f472d0bf4f9bac710d62"
+ },
+ "nvfp4": {
+ "base": "b1f4b9b4830c9bd43bf7c432cffe7dea21313537",
+ "patches": 4,
+ "source_commit": "e06a0cda2fd9fa6adcb5a309581b7691173bd1ce",
+ "binary_sha256": "5878ccbf11acd54b2f4da91bce97f0a3c121b1852fbf44e7134eb838fc145877"
+ }
+ },
+ "settings": {
+ "sample_rate_hz": 44100,
+ "channels": 1,
+ "sample_bits": 16,
+ "response_format": "pcm",
+ "max_frames": 1024,
+ "frame_cap_seconds": 47.55446712018141,
+ "context": 8192,
+ "parallel": 1,
+ "temperature": 1.0,
+ "top_p": 0.9,
+ "top_k": 30,
+ "diagnostic_seeds": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5
+ ],
+ "prompt_cache_gib": 0,
+ "keep_warm": 0,
+ "guidance": {
+ "full": "No classifier-free guidance in this pinned full profile.",
+ "nvfp4": {
+ "gamma": 1.5,
+ "batched": true
+ }
+ }
+ },
+ "reference_preparation": {
+ "kind": "synthetic",
+ "seeds": [
+ 42,
+ 43
+ ],
+ "transcripts": [
+ "The small boat crossed the lake.",
+ "A bright bird landed near the window."
+ ],
+ "procedure": "Each profile generated its own two built-in-voice references with the settings above. The full-profile drama3-generate tool encoded each WAV with that profile's codec.pth. Catalog transcripts used matching speaker tags 1/15, or 0/1 for the relabeling check. Generation was serial; each profile restarted after reference encoding and then served its case groups in the recorded order."
+ },
+ "pilot_checks": {
+ "full-references": {
+ "checkpoint_verified": true,
+ "checkpoint_revision": "e0a9218aa48888270f06ef1ed9ea67622c0e17d2",
+ "failures": [],
+ "admission_required_memavailable_bytes": 94489280512,
+ "observed_runtime_minimum_memavailable_bytes": 77733724160
+ },
+ "full-dialogue": {
+ "checkpoint_verified": true,
+ "checkpoint_revision": "e0a9218aa48888270f06ef1ed9ea67622c0e17d2",
+ "failures": [],
+ "admission_required_memavailable_bytes": 94489280512,
+ "observed_runtime_minimum_memavailable_bytes": 77570543616
+ },
+ "nvfp4-references": {
+ "checkpoint_verified": true,
+ "checkpoint_revision": "494db08481db952f834b2241bce63fb3dfa72554",
+ "failures": [],
+ "admission_required_memavailable_bytes": 94489280512,
+ "observed_runtime_minimum_memavailable_bytes": 98009337856
+ },
+ "nvfp4-dialogue": {
+ "checkpoint_verified": true,
+ "checkpoint_revision": "494db08481db952f834b2241bce63fb3dfa72554",
+ "failures": [],
+ "admission_required_memavailable_bytes": 94489280512,
+ "observed_runtime_minimum_memavailable_bytes": 97703804928
+ }
+ },
+ "build_checks": {
+ "full": "107/107 steps succeeded; 582/582 tests passed.",
+ "nvfp4": "106/106 steps succeeded; 870/879 tests passed, 9 skipped; cached steps retained.",
+ "initial_nvfp4_attempt": "A source archive lacked Git metadata required by a version-generation step: 101/106 steps succeeded, 1 failed; 1032/1041 tests passed, 9 skipped. The exact published source metadata was restored and the unchanged code passed on rerun."
+ },
+ "groups": [
+ {
+ "profile": "full",
+ "case": "synthetic-reference",
+ "voice": "default",
+ "attempts": 2,
+ "natural_stop": 2,
+ "frame_cap": 0,
+ "takes": [
+ {
+ "seed": 42,
+ "finish": "stop",
+ "audio_seconds": 1.7182766439909296,
+ "render_seconds": 1.8076799178961664,
+ "bytes": 151552,
+ "sha256": "1aeb3141f68254790433e3a06549052f165d51383f15cbf0cf771ac5ff88f12e",
+ "input": "The small boat crossed the lake."
+ },
+ {
+ "seed": 43,
+ "finish": "stop",
+ "audio_seconds": 3.575873015873016,
+ "render_seconds": 2.8071080818772316,
+ "bytes": 315392,
+ "sha256": "82cbebac79a6f088bcb8abfb233f620687f74ff83492bb495b2bfa3db93019b8",
+ "input": "A bright bird landed near the window."
+ }
+ ]
+ },
+ {
+ "profile": "full",
+ "case": "tagged-catalog",
+ "input": "<|speaker:1|> Is the train here? <|speaker:15|> It arrives at seven fifteen.",
+ "voice": "dialogue",
+ "attempts": 5,
+ "natural_stop": 2,
+ "frame_cap": 3,
+ "takes": [
+ {
+ "seed": 1,
+ "finish": "length",
+ "audio_seconds": 47.55446712018141,
+ "render_seconds": 35.86638974794187,
+ "bytes": 4194304,
+ "sha256": "9b64fb211ae96e0557279fdae9e9b9feb035f365b9dfcc4b4bb54aa481fe4faa"
+ },
+ {
+ "seed": 2,
+ "finish": "length",
+ "audio_seconds": 47.55446712018141,
+ "render_seconds": 35.588721685810015,
+ "bytes": 4194304,
+ "sha256": "1c43ec4d4f6f169b01f3758228dca6e2af3ba71073842be6106017435199c945"
+ },
+ {
+ "seed": 3,
+ "finish": "stop",
+ "audio_seconds": 4.551111111111111,
+ "render_seconds": 3.747302348027006,
+ "bytes": 401408,
+ "sha256": "dae64bd354b7a69d24c2ea4d6d8f51f68e664d0c0d927b51ea70642f6e69ebb0"
+ },
+ {
+ "seed": 4,
+ "finish": "length",
+ "audio_seconds": 47.55446712018141,
+ "render_seconds": 35.60359556088224,
+ "bytes": 4194304,
+ "sha256": "b66f41590b851282e83e3801a697bc51f44ba78eabd5934d007492b3237df02e"
+ },
+ {
+ "seed": 5,
+ "finish": "stop",
+ "audio_seconds": 5.015510204081632,
+ "render_seconds": 4.093490864848718,
+ "bytes": 442368,
+ "sha256": "8e3c5d5d253b544463aefe39ca23c1b5ade159f0846e94045b36d8da212bbb3e"
+ }
+ ]
+ },
+ {
+ "profile": "full",
+ "case": "untagged-catalog",
+ "input": " Is the train here? It arrives at seven fifteen.",
+ "voice": "dialogue",
+ "attempts": 5,
+ "natural_stop": 5,
+ "frame_cap": 0,
+ "takes": [
+ {
+ "seed": 1,
+ "finish": "stop",
+ "audio_seconds": 4.504671201814059,
+ "render_seconds": 3.715083193965256,
+ "bytes": 397312,
+ "sha256": "d98a4a35cf2b821874e5a3d9cc36fb679a0b4e4e9968187957c5a683438efa1d"
+ },
+ {
+ "seed": 2,
+ "finish": "stop",
+ "audio_seconds": 4.73687074829932,
+ "render_seconds": 3.8848382350988686,
+ "bytes": 417792,
+ "sha256": "6ae76381c3868a21e1d55ceb2abc146815918e6d1c693bc41e6c21bc9120b31e"
+ },
+ {
+ "seed": 3,
+ "finish": "stop",
+ "audio_seconds": 4.272471655328798,
+ "render_seconds": 3.5429713330231607,
+ "bytes": 376832,
+ "sha256": "f5166e194f9c37b1d765dc996a45e85a7a6e906a3e1d13c2ca88616e2d453ac1"
+ },
+ {
+ "seed": 4,
+ "finish": "stop",
+ "audio_seconds": 5.061950113378685,
+ "render_seconds": 4.12782311392948,
+ "bytes": 446464,
+ "sha256": "dfa57ffc2203ed703a7215dfec9cbbd1237bb6897ff39a8cf1e7439799d57b61"
+ },
+ {
+ "seed": 5,
+ "finish": "stop",
+ "audio_seconds": 3.250793650793651,
+ "render_seconds": 2.787320796167478,
+ "bytes": 286720,
+ "sha256": "ddb6d5410601c44ab6c68eeea7125cfad792299eaa025a13bb7097d0cd397241"
+ }
+ ]
+ },
+ {
+ "profile": "full",
+ "case": "tagged-built-in",
+ "input": "<|speaker:1|> Is the train here? <|speaker:15|> It arrives at seven fifteen.",
+ "voice": "default",
+ "attempts": 5,
+ "natural_stop": 4,
+ "frame_cap": 1,
+ "takes": [
+ {
+ "seed": 1,
+ "finish": "stop",
+ "audio_seconds": 4.876190476190477,
+ "render_seconds": 3.7644356288947165,
+ "bytes": 430080,
+ "sha256": "b718f42d5f899d4567b2be91ab14ec72e1b1e8621e97e1e27d2e392e5a1037ab"
+ },
+ {
+ "seed": 2,
+ "finish": "length",
+ "audio_seconds": 47.55446712018141,
+ "render_seconds": 35.331854237942025,
+ "bytes": 4194304,
+ "sha256": "91c309245da55174fc256b8b9815f0e1792d153e025a532864e84a8b003cce17"
+ },
+ {
+ "seed": 3,
+ "finish": "stop",
+ "audio_seconds": 4.365351473922902,
+ "render_seconds": 3.381959602003917,
+ "bytes": 385024,
+ "sha256": "dfd8764d4c186a12816e7c10b5addb79b2977989e838ea9725a9d5f666b8daa1"
+ },
+ {
+ "seed": 4,
+ "finish": "stop",
+ "audio_seconds": 4.73687074829932,
+ "render_seconds": 3.659604276996106,
+ "bytes": 417792,
+ "sha256": "38055c32b572cfa0609107387d9e8b26c36a33a22461ce17d579a6e37da8da03"
+ },
+ {
+ "seed": 5,
+ "finish": "stop",
+ "audio_seconds": 5.294149659863946,
+ "render_seconds": 4.076286201132461,
+ "bytes": 466944,
+ "sha256": "145e90c421b2c05f1e92c98856ddcb6e3da31b3cfcf6475e0c70049e07b8dc40"
+ }
+ ]
+ },
+ {
+ "profile": "full",
+ "case": "contiguous-catalog",
+ "input": "<|speaker:0|> Is the train here? <|speaker:1|> It arrives at seven fifteen.",
+ "voice": "contiguous",
+ "attempts": 5,
+ "natural_stop": 3,
+ "frame_cap": 2,
+ "takes": [
+ {
+ "seed": 1,
+ "finish": "stop",
+ "audio_seconds": 4.365351473922902,
+ "render_seconds": 3.6108327691908926,
+ "bytes": 385024,
+ "sha256": "ee34d18de1e4820f416d3a16e8459c4956b72fa7c2a4c7a981324bd1e6c16f6a"
+ },
+ {
+ "seed": 2,
+ "finish": "stop",
+ "audio_seconds": 4.2260317460317465,
+ "render_seconds": 3.505084641976282,
+ "bytes": 372736,
+ "sha256": "b37184cf166356d0bfd67fbb5d912fb5b7fb6be700c8daa4219ad23673e9de08"
+ },
+ {
+ "seed": 3,
+ "finish": "length",
+ "audio_seconds": 47.55446712018141,
+ "render_seconds": 35.61752852401696,
+ "bytes": 4194304,
+ "sha256": "0042057ea7aeab2cc49746e44475a3cd03aefd1725e2f58f6fc489dfad622ff8"
+ },
+ {
+ "seed": 4,
+ "finish": "stop",
+ "audio_seconds": 4.365351473922902,
+ "render_seconds": 3.6127231549471617,
+ "bytes": 385024,
+ "sha256": "51a76f6f21a6c91d1d61fd5f45979bc2b16821deed8049b92bd71fd4a00e049e"
+ },
+ {
+ "seed": 5,
+ "finish": "length",
+ "audio_seconds": 47.55446712018141,
+ "render_seconds": 35.6254837338347,
+ "bytes": 4194304,
+ "sha256": "a2b567299598be0742a101be582464a44052d06157850712ecd05621322cb8fa"
+ }
+ ]
+ },
+ {
+ "profile": "nvfp4",
+ "case": "synthetic-reference",
+ "voice": "default",
+ "attempts": 2,
+ "natural_stop": 2,
+ "frame_cap": 0,
+ "takes": [
+ {
+ "seed": 42,
+ "finish": "stop",
+ "audio_seconds": 2.2755555555555556,
+ "render_seconds": 1.530193794053048,
+ "bytes": 200704,
+ "sha256": "27a0518aa96b3a95783e6cd2ac8113f7220591de52c9aa6d0770fe16d68401c3",
+ "input": "The small boat crossed the lake."
+ },
+ {
+ "seed": 43,
+ "finish": "stop",
+ "audio_seconds": 2.043356009070295,
+ "render_seconds": 1.3589847041293979,
+ "bytes": 180224,
+ "sha256": "81015c7c2c83817e5f6541a789ba32aa5597e5f64c6ac85f9d8cfe33067625a9",
+ "input": "A bright bird landed near the window."
+ }
+ ]
+ },
+ {
+ "profile": "nvfp4",
+ "case": "tagged-catalog",
+ "input": "<|speaker:1|> Is the train here? <|speaker:15|> It arrives at seven fifteen.",
+ "voice": "dialogue",
+ "attempts": 5,
+ "natural_stop": 5,
+ "frame_cap": 0,
+ "takes": [
+ {
+ "seed": 1,
+ "finish": "stop",
+ "audio_seconds": 3.0650340136054424,
+ "render_seconds": 2.3493569360580295,
+ "bytes": 270336,
+ "sha256": "4107df8dc77683aefdd9b6516c86f34701ec61d32c997f990f40cfd05857b190"
+ },
+ {
+ "seed": 2,
+ "finish": "stop",
+ "audio_seconds": 3.900952380952381,
+ "render_seconds": 2.869977713096887,
+ "bytes": 344064,
+ "sha256": "11dad79a29646ab067e804d1abc5dd23d2bc2a189d48ad865976606b5467ff29"
+ },
+ {
+ "seed": 3,
+ "finish": "stop",
+ "audio_seconds": 3.01859410430839,
+ "render_seconds": 2.299853689968586,
+ "bytes": 266240,
+ "sha256": "b2958f99bdcec09c1aa09c53d41047bbcb67d63f6e5f5335cc4194a1758ccb3c"
+ },
+ {
+ "seed": 4,
+ "finish": "stop",
+ "audio_seconds": 3.947392290249433,
+ "render_seconds": 2.8945382239762694,
+ "bytes": 348160,
+ "sha256": "b283a60b5aad7549e99afcf435988a6031599ac8948bd0606b30f1c47c5a58e4"
+ },
+ {
+ "seed": 5,
+ "finish": "stop",
+ "audio_seconds": 4.783310657596372,
+ "render_seconds": 3.4300055550411344,
+ "bytes": 421888,
+ "sha256": "a0388a957b20f2608404aff50896e866c92c75e9316667a6750786b0dc4f721d"
+ }
+ ]
+ },
+ {
+ "profile": "nvfp4",
+ "case": "untagged-catalog",
+ "input": " Is the train here? It arrives at seven fifteen.",
+ "voice": "dialogue",
+ "attempts": 5,
+ "natural_stop": 5,
+ "frame_cap": 0,
+ "takes": [
+ {
+ "seed": 1,
+ "finish": "stop",
+ "audio_seconds": 5.061950113378685,
+ "render_seconds": 3.6132642519660294,
+ "bytes": 446464,
+ "sha256": "d3ad6f38b974d3b4898aa2987b6cbdb2b54c9a040a041ae63d5c826faf52a1ce"
+ },
+ {
+ "seed": 2,
+ "finish": "stop",
+ "audio_seconds": 3.111473922902494,
+ "render_seconds": 2.3578906329348683,
+ "bytes": 274432,
+ "sha256": "3bebcbdfaa57338813495872215b5197872633249fda4777b05100719528a2ab"
+ },
+ {
+ "seed": 3,
+ "finish": "stop",
+ "audio_seconds": 4.133151927437642,
+ "render_seconds": 3.0144398738630116,
+ "bytes": 364544,
+ "sha256": "d5863b676f0176e9da8b421dea3f631979bb3176f3c0ad0b5d65bfa9e6d94f49"
+ },
+ {
+ "seed": 4,
+ "finish": "stop",
+ "audio_seconds": 4.31891156462585,
+ "render_seconds": 3.1373087589163333,
+ "bytes": 380928,
+ "sha256": "1ec37c6eeeb02cc7ec7d584495bb06f3beb00a34fdb36b4f6368bcd46b1ced20"
+ },
+ {
+ "seed": 5,
+ "finish": "stop",
+ "audio_seconds": 3.1579138321995464,
+ "render_seconds": 2.3858531878795475,
+ "bytes": 278528,
+ "sha256": "8ef606945f4cfa92fe961a0e0bd7d2d5ea838ec96b8a1b1efcb5aac037dec5c9"
+ }
+ ]
+ }
+ ],
+ "limits": [
+ "This measures finish reasons, PCM byte counts and elapsed times, not intelligibility or speaker identity; no listening review was performed.",
+ "The profiles differ in engine pin, weights and guidance, so the comparison does not isolate quantization as the cause.",
+ "Only the two public reference sentences and one dialogue were sampled; no wider reliability claim.",
+ "Metal behavior and the reference Python runtime were not tested in this follow-up.",
+ "Raw requests, PCM/WAV, catalogs and logs are retained privately; this file is a neutral summary.",
+ "An earlier supervisor attempt stopped during service preparation before inference. It produced no audio samples; the diagnostic run began after restoring the services and correcting the supervisor lifetime."
+ ],
+ "follow_up": "https://github.com/plotarmordev/drama-tensorfold/issues/32",
+ "pilot_memory_guards": {
+ "admission_available_gib": 88,
+ "cgroup_limit_gib": 54,
+ "abort_available_gib": 34,
+ "unchanged": true
+ }
+}