Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

- Flash Next on CUDA with NVFP4 checkpoints prefills 24-29% faster with the same tokens: NVFP4 experts on prompt tiles with the decode form's sums (#1143), no zero fills the one-rank ModelOpt pass reads back (#1147), the hyper-connection normalize over 256-column slices and the dense mix a warp a 32-column group (#1164), and half tiles for decode calls whose 64-column grid leaves SMs idle (#1171). On a GB10 with Qwen3.8 Flash Next FP4-Local, prefill at 16k goes from 1,357 to 1,748 tokens/s and decode gains 1-2%. Attention caches are readable through a page table and the streams beside the first take KV pages from the core pool (#1169, #1170).

- GLM-5.3-Flash on Metal decodes faster with the same tokens by reading the weights once per decode window (#1130): on an M3 Ultra, +2.6% at one stream and +10.2%, +6.4% and +6.3% at two, four and eight.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

### GLM-5.3: one machine or two?

Expand Down Expand Up @@ -293,7 +293,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. |
Expand Down
10 changes: 8 additions & 2 deletions docs/speech-redraws.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions docs/speech-sampling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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 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.
1 change: 1 addition & 0 deletions zig/src/server/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ test {
_ = @import("status_routes.zig");
_ = @import("speech.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
Expand Down
23 changes: 13 additions & 10 deletions zig/src/server/speech.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 ids = @import("ids.zig");
Expand All @@ -29,7 +30,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,
Expand All @@ -43,7 +44,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 };
Expand Down Expand Up @@ -86,6 +87,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});
Expand All @@ -104,6 +107,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 });
Expand All @@ -122,9 +126,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.
Expand Down Expand Up @@ -172,11 +176,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,
};
Expand Down Expand Up @@ -249,7 +253,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);
Expand Down Expand Up @@ -539,7 +543,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" },
Expand Down
28 changes: 28 additions & 0 deletions zig/src/server/speech_register_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading