Skip to content

feat(server): audio endpoint plumbing (multipart upload, binary response, WAV output) - #368

Merged
inureyes merged 5 commits into
mainfrom
feature/364-audio-endpoint-plumbing
Jun 19, 2026
Merged

inureyes merged 5 commits into
mainfrom
feature/364-audio-endpoint-plumbing

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

Phase 1 server plumbing for the OpenAI-compatible audio API surface (part of epic #363). This registers the speech and transcription/translation routes, parses their request shapes (JSON for text-to-speech, multipart/form-data for speech-to-text), and adds the audio-output WAV encoder. No speech model is implemented here; every audio route returns a structured HTTP 501 "audio model kind not loaded" until the model sub-issues land. The audio-model interface is deliberately generic so the speech-to-text and text-to-speech work plugs in without reworking the server surface.

What changed

  • Added the multipart axum feature and a multipart parser for the speech-to-text upload (the file part plus the model, language, response_format, and temperature fields) via axum::extract::Multipart.
  • Registered six routes in create_app (src/server/app.rs): /audio/speech, /audio/transcriptions, and /audio/translations, each in both the unversioned and /v1-prefixed forms.
  • Added src/server/routes/audio.rs: the JSON text-to-speech handler returns a binary audio/wav body with a Content-Disposition: attachment; filename=speech.wav header; the multipart speech-to-text handlers parse the upload and return OpenAI transcription JSON. All handlers reuse state.can_accept_request() admission control, the existing request metrics, and ErrorResponse.
  • Added a generic audio-model seam in src/server/audio_model.rs: an AudioModelKind { Stt, Tts } enum plus an AudioModelProvider trait whose transcribe and synthesize methods default to a "kind not loaded" error, so each direction can be implemented independently. Added an Option<Arc<dyn AudioModelProvider>> slot to AppState (None in this phase) with a with_audio_model builder.
  • Added request/response types in src/server/types/: AudioSpeechRequest, AudioTranscriptionRequest, and AudioTranscriptionResponse, plus an ErrorResponse::not_implemented (HTTP 501) constructor.
  • Added src/audio/wav_writer.rs with encode_wav_pcm16, a PCM16 little-endian RIFF WAV encoder that is the counterpart to the existing reader. The reader is reused, not duplicated.
  • Noted the new endpoint families in docs/architecture.md.

Behavior in this phase

All six routes are mounted and reachable. The text-to-speech route parses its JSON body and the speech-to-text routes parse their multipart body through the real request flow; with no model loaded each returns a structured HTTP 501 with the message "audio model kind not loaded". The synthesize and transcribe code paths (binary WAV response, transcription JSON) are written and compile so the model sub-issues only need to supply an AudioModelProvider.

Test plan

  • cargo check --lib --tests --features metal,accelerate
  • cargo clippy --lib --tests --features metal,accelerate -- -D warnings (clean)
  • cargo fmt --check (clean)
  • cargo test --lib --features metal,accelerate audio::wav_writer (WAV encode round-trips through load_wav_from_bytes)
  • cargo test --lib --features metal,accelerate server::audio_model
  • cargo test --lib --features metal,accelerate server::routes::audio

Closes #364

Phase 1 server foundation for the OpenAI-compatible audio API surface. Registers the speech and transcription/translation routes, parses their request shapes, and returns a structured 501 "audio model kind not loaded" until a speech model is wired in. No model is added here.

Changes:

- Add the `multipart` axum feature and parse the speech-to-text upload via `axum::extract::Multipart` (the file part plus the model, language, response_format, and temperature fields).
- Register six routes in `create_app`: `/audio/speech`, `/audio/transcriptions`, and `/audio/translations`, each in both the unversioned and `/v1`-prefixed forms.
- Add `src/server/routes/audio.rs` with the JSON text-to-speech handler (binary `audio/wav` body with a `Content-Disposition` attachment header) and the multipart speech-to-text handlers, reusing `can_accept_request` admission control, the request metrics, and `ErrorResponse`.
- Define a generic audio-model seam in `src/server/audio_model.rs`: an `AudioModelKind` enum plus an `AudioModelProvider` trait with independent transcribe and synthesize methods, so the speech-to-text and text-to-speech work plugs in without reworking the route layer. Add an `Option` provider slot to `AppState`, left `None` in this phase.
- Add `AudioSpeechRequest` and `AudioTranscriptionRequest` request types and an `AudioTranscriptionResponse`, plus an `ErrorResponse::not_implemented` (501) constructor.
- Add a PCM16 little-endian RIFF WAV writer (`encode_wav_pcm16`) in `src/audio/wav_writer.rs` as the counterpart to the existing reader, with a unit test that round-trips encoded audio back through `load_wav_from_bytes`.
- Note the new endpoint families in `docs/architecture.md`.
@inureyes inureyes added type:enhancement New features, capabilities, or significant additions priority:medium Medium priority area:inference Generation, sampling, decoding (incl. speculative, DRY) status:review Under review labels Jun 19, 2026
inureyes added 2 commits June 19, 2026 13:06
Raise the request body limit on the six /audio/* routes to 25 MiB through a dedicated sub-router carrying DefaultBodyLimit, so realistic audio uploads are not rejected by Axum's 2 MiB default before the handler runs. The outer auth, CORS, and trace layers still apply to the merged routes.

Reject unsupported transcription response_format values (for example srt, vtt, or a typo) with a 400 instead of silently returning JSON, matching the speech path's resolve_audio_format strictness. The check stays on the post-provider success path so the Phase 1 501 still dominates for every format.

Add unit tests that drive the multipart parser with real multipart/form-data bytes (file part round-trip, scalar fields, empty and invalid temperature, unknown-part draining, missing file), add a deserialize test for AudioTranscriptionRequest, and fix the self-contradictory doc comment on that type.
encode_wav_pcm16 truncated samples.len() to u32 before the saturating multiply, so an input larger than u32::MAX samples advertised a RIFF data length that no longer matched the bytes written, producing a corrupt header. Cap the encoded sample count to what a 32-bit RIFF data length can represent so the advertised length and the emitted payload always agree. Behavior for normal inputs is unchanged. Add regression tests covering header and payload length consistency and confirming that pathological f32 input (NaN, infinities, out of range) saturates without panicking.
@inureyes

Copy link
Copy Markdown
Member Author

Security and performance review: Phase 1 audio plumbing

Reviewed the new audio surface: the multipart upload and JSON parsing in src/server/routes/audio.rs, the WAV PCM16 encoder in src/audio/wav_writer.rs, the AudioModelProvider seam, the body-limit wiring in src/server/app.rs, and the 501/400 error paths. The intentional 501 "audio model kind not loaded" boundary and the absence of a model are by design and were not flagged.

Findings by severity

  • CRITICAL: none
  • HIGH: none
  • MEDIUM: none
  • LOW (auto-fixed in 9395102): encode_wav_pcm16 derived the RIFF data and RIFF length fields from samples.len() as u32, which truncates the length to 32 bits before the saturating multiply. For an input above u32::MAX samples the advertised length stopped matching the bytes actually written, producing a corrupt header. There was no panic or memory-safety issue (the float to i16 cast and the size math already use saturating semantics), but the header could disagree with the payload. The fix caps the encoded sample count to what a 32-bit RIFF length can represent so header and payload always agree; output for normal inputs is byte-for-byte unchanged. Two regression tests were added: header and payload length consistency, and pathological f32 input (NaN, infinities, out of range) saturating without panic.

Confirmed safe (no change needed)

  • Upload size is bounded before parsing. The DefaultBodyLimit::max(25 MiB) on the audio sub-router is honored by both the Json extractor (text-to-speech) and the Multipart extractor (speech-to-text); in axum 0.7.9 Multipart::from_request wraps the body with with_limited_body(), so the multipart stream cannot exceed the limit. No unbounded in-memory buffering.
  • Malformed, empty, truncated, or oversized multipart surfaces as a structured 400. There is no unwrap, expect, or indexing panic in the parse path. Unknown parts are drained with a bounded read so the stream advances.
  • The WAV float to i16 conversion saturates for out-of-range values and maps NaN to 0; it cannot panic or produce UB.
  • The binary response uses a fixed Content-Disposition: attachment; filename=speech.wav with a whitelisted extension. The uploaded filename is output-only and never used in a filesystem path, so there is no path traversal or header injection.
  • Model work runs under spawn_blocking; a task panic is caught and mapped to a generic 500 with no internal detail. The 501 and 400 envelopes do not leak stack traces or internal paths.
  • The audio routes sit behind the same API-key auth, CORS, and trace layers as the rest of the server, and admission control (can_accept_request) is a non-acquiring queue-depth check, so there is no slot leak.

Verification

  • cargo test --lib --features metal,accelerate audio::wav_writer: 6 passed
  • cargo clippy --lib --tests --features metal,accelerate -- -D warnings: clean

Note (no action this phase)

  • The temperature multipart field parses via f32::parse, which accepts NaN and infinity. The value is optional and currently forwarded only to a future model, so sampling-parameter validation belongs with the speech-to-text model when it lands.

No CRITICAL or HIGH items remain. The PR was not merged.

inureyes added 2 commits June 19, 2026 13:34
Seven tests in server::app::tests covering the Phase 1 audio route surface that
the existing per-module tests could not reach because they only exercise internal
functions, not the router itself.

- audio_upload_limit_is_25_mib: verifies AUDIO_MAX_UPLOAD_BYTES == 25 MiB.
- audio_speech_is_reachable_at_v1_path, audio_transcriptions_is_reachable_at_v1_path,
  audio_translations_is_reachable_at_v1_path: confirm each v1 path returns non-404 for POST.
- get_to_audio_speech_returns_method_not_allowed: 405 (not 404) proves the route is mounted.
- audio_alias_paths_are_reachable_without_v1_prefix: checks the /audio/* alias set.
- body_limit_layer_enforces_upload_cap: confirms DefaultBodyLimit is wired onto the
  audio sub-router; uses a small test limit to avoid allocating 25 MiB in the test binary.
New docs/audio-api.md documents the three endpoint families added in this PR:
/v1/audio/speech (TTS), /v1/audio/transcriptions, and /v1/audio/translations
(STT). Covers request/response shape, supported fields, error codes, Phase 1
501 behavior, the ordering of parse-before-model-check, WAV encoding details,
and how to wire in a future AudioModelProvider.

docs/README.md updated to list audio-api.md in the GitHub-facing docs index.
@inureyes

Copy link
Copy Markdown
Member Author

PR Finalization Complete

Tests

Added 7 tests to server::app::tests covering the route registration surface that the existing per-module unit tests could not reach (they exercise internal functions, not the router itself):

  • audio_upload_limit_is_25_mib: constant check that AUDIO_MAX_UPLOAD_BYTES is 25 MiB.
  • audio_speech_is_reachable_at_v1_path, audio_transcriptions_is_reachable_at_v1_path, audio_translations_is_reachable_at_v1_path: each v1 path returns non-404 for POST, confirming the route is mounted.
  • get_to_audio_speech_returns_method_not_allowed: 405 (not 404) proves the route is registered and only accepts POST.
  • audio_alias_paths_are_reachable_without_v1_prefix: verifies the /audio/* alias set.
  • body_limit_layer_enforces_upload_cap: confirms DefaultBodyLimit is wired onto the audio sub-router; uses a small test limit to avoid allocating 25 MiB in the binary.

Existing test counts unchanged: 16 in server::routes::audio, 6 in audio::wav_writer, 3 in server::audio_model. All pass.

Documentation

New docs/audio-api.md documents the three endpoint families. Covers request and response shape, all field semantics, error codes, the Phase 1 "501 until model loaded" boundary, the parse-before-model-check ordering, WAV encoding behavior, and how to register a future AudioModelProvider.

docs/README.md updated to list audio-api.md in the GitHub-facing docs index.

Lint/Format

cargo fmt --check: clean. cargo clippy --lib --tests --features metal,accelerate -- -D warnings: clean.

@inureyes inureyes added status:done Completed and removed status:review Under review labels Jun 19, 2026
@inureyes
inureyes merged commit e4ec44a into main Jun 19, 2026
5 checks passed
@inureyes
inureyes deleted the feature/364-audio-endpoint-plumbing branch June 19, 2026 05:22
inureyes added a commit that referenced this pull request Jun 20, 2026
Audio I/O on the OpenAI /v1/audio/* surface (Whisper speech-to-text #371,
Kokoro-82M text-to-speech #374, endpoint plumbing #368), reasoning_content on
non-streaming chat (#359), pre-M5 decode +8-12% via a hardware-gated op cap
(#360), Turbo4Asym decode rerouted through dequant-SDPA (#369), batched
sampling fixes (#339, #340, #344, #356), and the Turbo KV advisor/docs aligned
to the measured sweep with ADR 0002 (#343, #376).

README: consolidate the v0.3.0 and v0.3.1 highlights under a single v0.3 entry
and add v0.3.2.
@inureyes inureyes added this to the 0.3 milestone Jun 21, 2026
@inureyes inureyes self-assigned this Aug 31, 2026
be-free-snowboy pushed a commit to freestylejs/snowbrain-mlxcel that referenced this pull request Oct 27, 2026
Closes lablup#364. Phase 1 of the OpenAI audio endpoints epic (lablup#363): routes for /v1/audio/{speech,transcriptions,translations}, multipart upload parsing, binary audio responses, a WAV PCM16 writer, and the AudioModelProvider seam returning 501 until the STT and TTS models land.
be-free-snowboy pushed a commit to freestylejs/snowbrain-mlxcel that referenced this pull request Oct 27, 2026
Audio I/O on the OpenAI /v1/audio/* surface (Whisper speech-to-text lablup#371,
Kokoro-82M text-to-speech lablup#374, endpoint plumbing lablup#368), reasoning_content on
non-streaming chat (lablup#359), pre-M5 decode +8-12% via a hardware-gated op cap
(lablup#360), Turbo4Asym decode rerouted through dequant-SDPA (lablup#369), batched
sampling fixes (lablup#339, lablup#340, lablup#344, lablup#356), and the Turbo KV advisor/docs aligned
to the measured sweep with ADR 0002 (lablup#343, lablup#376).

README: consolidate the v0.3.0 and v0.3.1 highlights under a single v0.3 entry
and add v0.3.2.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:inference Generation, sampling, decoding (incl. speculative, DRY) priority:medium Medium priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(server): audio endpoint plumbing (multipart upload, binary response, WAV output)

1 participant