Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ 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)).
- 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))
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand Down
36 changes: 26 additions & 10 deletions docs/drama-voices.md

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions docs/speech-sampling.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion zig/src/server/cli_speech.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
11 changes: 9 additions & 2 deletions zig/src/server/drama_joins.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading