Skip to content
Merged
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
3 changes: 2 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,8 @@ copies `kokoro-server` next to `libkokoro`; `build.rs` links against the library
- `AppState` (`routes.rs`) keeps the engine behind a `tokio::sync::Mutex`. A request waits for the lock without a
thread, then synthesizes in `spawn_blocking`, which also reads the thread-local `kokoro_last_error()`.
- `POST /v1/audio/speech` parses the JSON body (`speech_request.rs`), then encodes the float samples as 16-bit PCM
WAV or raw PCM (`audio_encoding.rs`). Other formats get an OpenAI-style 400 error.
WAV or raw PCM (`audio_encoding.rs`). Other formats get an OpenAI-style 400 error. `input` and `voice` must not
contain a NUL byte (C strings would silently truncate it); such requests get a 400 naming the field.
- `GET /v1/audio/voices` lists the loaded voices; `GET /health` answers `{"status":"ok"}`.
- The listen socket (`listener.rs`) sets `SO_REUSEADDR` (`SO_EXCLUSIVEADDRUSE` on Windows) and never
`SO_REUSEPORT`, so a second server on the same port fails instead of sharing it.
Expand Down
12 changes: 9 additions & 3 deletions src/server/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,15 @@ impl Engine {
}

pub fn synthesize(&mut self, text: &str, voice: &str, speed: f32) -> Result<Audio, SynthesisError> {
// C strings end at the first NUL, as with the C++ server's c_str().
let c_prefix = |s: &str| CString::new(s.split('\0').next().unwrap_or_default()).expect("prefix has no NUL");
let (text, voice) = (c_prefix(text), c_prefix(voice));
// Reject rather than truncate: a NUL would silently end the string at the C boundary, losing the tail.
let c_arg = |text: &str| {
c_string(text).map_err(|message| SynthesisError {
status: kokoro_status::ERROR_INVALID_ARGUMENT,
message,
})
};
let text = c_arg(text)?;
let voice = c_arg(voice)?;
let mut audio = Audio(kokoro_audio { samples: ptr::null_mut(), num_samples: 0, sample_rate: 0 });
// SAFETY: self.ctx is live and exclusively borrowed; strings outlive the call; audio.0 is a valid out value.
let status = unsafe { ffi::kokoro_synthesize(self.ctx, text.as_ptr(), voice.as_ptr(), speed, 0, &mut audio.0) };
Expand Down
39 changes: 30 additions & 9 deletions src/server/speech_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,30 @@ fn is_blank(text: &str) -> bool {
text.bytes().all(|b| b == b' ' || (b'\t'..=b'\r').contains(&b))
}

fn parse_input(body: &Body) -> Result<String, RequestError> {
match optional_member(body, "input").and_then(Value::as_str) {
Some(text) if !is_blank(text) => Ok(text.to_owned()),
_ => fail("'input' must be a non-empty string", Some("input")),
// C strings end at the first NUL byte, so a NUL would silently truncate the value at the engine boundary.
fn check_no_nul(text: &str, field: &'static str) -> Result<(), RequestError> {
if text.contains('\0') {
fail(format!("'{}' must not contain a NUL byte", field), Some(field))
} else {
Ok(())
}
}

fn parse_input(body: &Body) -> Result<String, RequestError> {
let text = match optional_member(body, "input").and_then(Value::as_str) {
Some(text) if !is_blank(text) => text,
_ => return fail("'input' must be a non-empty string", Some("input")),
};
check_no_nul(text, "input").map(|()| text.to_owned())
}

fn parse_voice(body: &Body, default_voice: &str) -> Result<String, RequestError> {
match optional_member(body, "voice") {
None => Ok(default_voice.to_owned()),
Some(Value::String(voice)) => Ok(voice.clone()),
Some(_) => fail("'voice' must be a string", Some("voice")),
}
let voice = match optional_member(body, "voice") {
None => return Ok(default_voice.to_owned()),
Some(Value::String(voice)) => voice,
Some(_) => return fail("'voice' must be a string", Some("voice")),
};
check_no_nul(voice, "voice").map(|()| voice.clone())
}

fn parse_speed(body: &Body) -> Result<f32, RequestError> {
Expand Down Expand Up @@ -138,6 +149,16 @@ mod tests {
assert_eq!(request.format, ResponseFormat::Wav);
}

#[test]
fn nul_bytes_are_rejected_instead_of_truncated() {
let input = rejected(r#"{"input":"Hello\u0000world"}"#);
assert_eq!(input.message, "'input' must not contain a NUL byte");
assert_eq!(input.param, Some("input"));
let voice = rejected(r#"{"input":"x","voice":"af_maple\u0000"}"#);
assert_eq!(voice.message, "'voice' must not contain a NUL byte");
assert_eq!(voice.param, Some("voice"));
}

#[test]
fn accepted_values() {
let request =
Expand Down
8 changes: 8 additions & 0 deletions src/server/tests/server_synthesis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ fn server_synthesizes_audio() {
assert_eq!((pcm.status, pcm.content_type.as_str()), (200, "audio/pcm"));
assert!(!pcm.body.is_empty() && pcm.body.len() % 2 == 0);

// A NUL byte must produce a 400 naming the field, never 200 with truncated audio.
let nul_input = post(&agent, &url, r#"{"input":"Hello\u0000world"}"#);
assert_eq!(nul_input.status, 400);
assert_eq!(json(&nul_input)["error"]["param"], "input");
let nul_voice = post(&agent, &url, r#"{"input":"Hello world","voice":"af_maple\u0000"}"#);
assert_eq!(nul_voice.status, 400);
assert_eq!(json(&nul_voice)["error"]["param"], "voice");

let voice = post(&agent, &url, r#"{"input":"Hello world","voice":"nope"}"#);
assert_eq!(voice.status, 400);
assert_eq!(json(&voice)["error"]["param"], "voice");
Expand Down
Loading