Skip to content

Commit b32a523

Browse files
CryptVentureclaude
andcommitted
server: add /v1/audio/transcriptions/details for transcript detail fields
ASR models that align words, segment speech or separate speakers report that work through TaskResult::word_timestamps, speech_segments and speaker_turns. /v1/audio/transcriptions serialises text and timing only, so for those models the alignment is computed and then discarded on the way out of the server. Rather than widen the existing response, which callers already build against, this adds an opt-in route with the same request shape. /v1/audio/transcriptions and /v1/tasks/run are byte-identical to before. The detail response is a superset of the plain one: text first, timing last, with language, segments, speaker_turns and words in between where the model produced them. Spans are sample offsets because that is what the models report, so sample_rate travels with them and is emitted only when at least one of the arrays is present. stream=true is rejected with a 400 on the detail route. The SSE response carries transcript deltas only and has nowhere to put the arrays, so accepting the request would return none of what the route exists to return. The serialisation the generic task route already performed is factored into write_transcript_detail_fields and shared, rather than duplicated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATa5YkLUPMDPRL7w1gCo9p
1 parent a088f90 commit b32a523

4 files changed

Lines changed: 165 additions & 67 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,7 @@ The server exposes:
653653
- `GET /v1/models`
654654
- `POST /v1/audio/speech`
655655
- `POST /v1/audio/transcriptions`
656+
- `POST /v1/audio/transcriptions/details`
656657
- `POST /v1/audio/alignments`
657658
- `POST /v1/tasks/run`
658659

app/server/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,44 @@ The stream emits `transcript.text.delta` events, one final `transcript.text.done
380380

381381
Note that `stream=true` streams the *output* of an already-uploaded file: the whole recording is sent first, and the deltas describe decoding it. It shortens time-to-first-token on long audio, but nothing can appear while the speaker is still talking. For that, use the live endpoint below.
382382

383+
### `POST /v1/audio/transcriptions/details`
384+
385+
Same request as `POST /v1/audio/transcriptions` — JSON with a server-local path, or a `multipart/form-data` upload — with a richer response. Use it when the model produces timestamps or speaker labels and the caller wants them.
386+
387+
`/v1/audio/transcriptions` returns `text` and `timing` and nothing else, so a model that aligned every word or separated speakers has that work discarded on the way out. This route returns those fields instead. The response schema of the plain route is unchanged; existing clients see exactly what they see today.
388+
389+
```bash
390+
curl http://127.0.0.1:8080/v1/audio/transcriptions/details \
391+
-F model=parakeet-tdt \
392+
-F file=@/path/to/input.wav
393+
```
394+
395+
```json
396+
{
397+
"text": "the task has completed successfully",
398+
"language": "en",
399+
"words": [
400+
{"word": "the", "start_sample": 3200, "end_sample": 6400, "confidence": 0.98}
401+
],
402+
"sample_rate": 16000,
403+
"timing": { "wall_ms": 412.7, "audio_seconds": 2.4, "rtf": 0.17 }
404+
}
405+
```
406+
407+
`text` and `timing` are always present and match the plain route. The rest appear only when the model produced them:
408+
409+
| Field | Present when | Contents |
410+
|---|---|---|
411+
| `language` | the model reports a detected or configured language | Language code. |
412+
| `segments` | the model produces speech segments | `start_sample`, `end_sample`, `confidence`, and `text` where the segment carries it. |
413+
| `speaker_turns` | the model diarizes | `start_sample`, `end_sample`, `speaker_id`, `confidence`, and `text` where present. |
414+
| `words` | the model aligns words | `word`, `start_sample`, `end_sample`, `confidence`. |
415+
| `sample_rate` | any of the three arrays above is present | Rate the sample offsets are counted in. Divide an offset by it for seconds. |
416+
417+
Spans are sample offsets rather than seconds because that is what the models report; `sample_rate` is what converts them, which is why it only appears alongside them.
418+
419+
`stream=true` is rejected with a 400 on this route: the SSE response carries transcript deltas only, so it has nowhere to put the detail arrays. Use `/v1/audio/transcriptions` for a streamed transcript.
420+
383421
### `POST /v1/audio/alignments`
384422

385423
Multipart forced-alignment request using uploaded audio bytes and a known transcript. Use this when the server cannot see the client's local audio path, for example when the server is remote or running in Docker.

app/server/runtime.cpp

Lines changed: 118 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,68 @@ std::unordered_map<std::string, std::string> timing_headers(
657657
};
658658
}
659659

660+
// Transcript detail arrays shared by /v1/tasks/run and /v1/audio/transcriptions.
661+
// ASR models that produce timestamps populate only these fields, so a route that
662+
// omits them silently discards work the model already did.
663+
template <typename FieldFn>
664+
void write_transcript_detail_fields(
665+
std::ostringstream & out,
666+
const engine::runtime::TaskResult & result,
667+
FieldFn field) {
668+
if (!result.speech_segments.empty()) {
669+
field("segments");
670+
out << "[";
671+
for (size_t i = 0; i < result.speech_segments.size(); ++i) {
672+
if (i != 0) {
673+
out << ",";
674+
}
675+
const auto & segment = result.speech_segments[i];
676+
out << "{\"start_sample\":" << segment.span.start_sample
677+
<< ",\"end_sample\":" << segment.span.end_sample
678+
<< ",\"confidence\":" << segment.confidence;
679+
if (!segment.text.empty()) {
680+
out << ",\"text\":" << json_quote(segment.text);
681+
}
682+
out << "}";
683+
}
684+
out << "]";
685+
}
686+
if (!result.speaker_turns.empty()) {
687+
field("speaker_turns");
688+
out << "[";
689+
for (size_t i = 0; i < result.speaker_turns.size(); ++i) {
690+
if (i != 0) {
691+
out << ",";
692+
}
693+
const auto & turn = result.speaker_turns[i];
694+
out << "{\"start_sample\":" << turn.span.start_sample
695+
<< ",\"end_sample\":" << turn.span.end_sample
696+
<< ",\"speaker_id\":" << json_quote(turn.speaker_id)
697+
<< ",\"confidence\":" << turn.confidence;
698+
if (!turn.text.empty()) {
699+
out << ",\"text\":" << json_quote(turn.text);
700+
}
701+
out << "}";
702+
}
703+
out << "]";
704+
}
705+
if (!result.word_timestamps.empty()) {
706+
field("words");
707+
out << "[";
708+
for (size_t i = 0; i < result.word_timestamps.size(); ++i) {
709+
if (i != 0) {
710+
out << ",";
711+
}
712+
const auto & word = result.word_timestamps[i];
713+
out << "{\"word\":" << json_quote(word.word)
714+
<< ",\"start_sample\":" << word.span.start_sample
715+
<< ",\"end_sample\":" << word.span.end_sample
716+
<< ",\"confidence\":" << word.confidence << "}";
717+
}
718+
out << "]";
719+
}
720+
}
721+
660722
std::string task_result_json_with_timing(
661723
const engine::runtime::TaskResult & result,
662724
const std::string & timing) {
@@ -727,58 +789,7 @@ std::string task_result_json_with_timing(
727789
for (const auto & artifact : result.output_artifacts) write_artifact(artifact);
728790
out << "]";
729791
}
730-
if (!result.speech_segments.empty()) {
731-
field("segments");
732-
out << "[";
733-
for (size_t i = 0; i < result.speech_segments.size(); ++i) {
734-
if (i != 0) {
735-
out << ",";
736-
}
737-
const auto & segment = result.speech_segments[i];
738-
out << "{\"start_sample\":" << segment.span.start_sample
739-
<< ",\"end_sample\":" << segment.span.end_sample
740-
<< ",\"confidence\":" << segment.confidence;
741-
if (!segment.text.empty()) {
742-
out << ",\"text\":" << json_quote(segment.text);
743-
}
744-
out << "}";
745-
}
746-
out << "]";
747-
}
748-
if (!result.speaker_turns.empty()) {
749-
field("speaker_turns");
750-
out << "[";
751-
for (size_t i = 0; i < result.speaker_turns.size(); ++i) {
752-
if (i != 0) {
753-
out << ",";
754-
}
755-
const auto & turn = result.speaker_turns[i];
756-
out << "{\"start_sample\":" << turn.span.start_sample
757-
<< ",\"end_sample\":" << turn.span.end_sample
758-
<< ",\"speaker_id\":" << json_quote(turn.speaker_id)
759-
<< ",\"confidence\":" << turn.confidence;
760-
if (!turn.text.empty()) {
761-
out << ",\"text\":" << json_quote(turn.text);
762-
}
763-
out << "}";
764-
}
765-
out << "]";
766-
}
767-
if (!result.word_timestamps.empty()) {
768-
field("words");
769-
out << "[";
770-
for (size_t i = 0; i < result.word_timestamps.size(); ++i) {
771-
if (i != 0) {
772-
out << ",";
773-
}
774-
const auto & word = result.word_timestamps[i];
775-
out << "{\"word\":" << json_quote(word.word)
776-
<< ",\"start_sample\":" << word.span.start_sample
777-
<< ",\"end_sample\":" << word.span.end_sample
778-
<< ",\"confidence\":" << word.confidence << "}";
779-
}
780-
out << "]";
781-
}
792+
write_transcript_detail_fields(out, result, field);
782793
field("timing");
783794
out << timing;
784795
out << "}";
@@ -1162,6 +1173,13 @@ HttpResponse ServerState::handle(const HttpRequest & request) {
11621173
else if (request.method == "POST" && request.path == "/v1/audio/transcriptions") {
11631174
response = handle_transcription(request);
11641175
}
1176+
// Same request shape as the route above, opt-in richer response: the models
1177+
// that align words or separate speakers report them through fields the
1178+
// transcription response drops. A separate path rather than a flag keeps the
1179+
// existing response schema fixed for every client already built against it.
1180+
else if (request.method == "POST" && request.path == "/v1/audio/transcriptions/details") {
1181+
response = handle_transcription(request, /*detail=*/true);
1182+
}
11651183
else if (request.method == "POST" && request.path == "/v1/audio/alignments") {
11661184
response = handle_alignment(request);
11671185
}
@@ -2493,35 +2511,46 @@ HttpResponse ServerState::handle_speech_live(const HttpRequest & request) {
24932511
});
24942512
}
24952513

2496-
HttpResponse ServerState::handle_transcription(const HttpRequest & request) {
2514+
// The streaming response carries transcript deltas only, so it has nowhere to put
2515+
// the detail arrays. Refusing is better than accepting the request and silently
2516+
// returning none of what the route exists to return.
2517+
constexpr const char * kDetailStreamUnsupported =
2518+
"streaming is not supported on /v1/audio/transcriptions/details; "
2519+
"use /v1/audio/transcriptions for a streamed transcript";
2520+
2521+
HttpResponse ServerState::handle_transcription(const HttpRequest & request, bool detail) {
24972522
std::string content_type;
24982523
if (const auto it = request.headers.find("content-type"); it != request.headers.end()) {
24992524
content_type = it->second;
25002525
}
25012526
if (const auto boundary = extract_multipart_boundary(content_type)) {
2502-
return handle_transcription_multipart(request.body, *boundary);
2527+
return handle_transcription_multipart(request.body, *boundary, detail);
25032528
}
2504-
return handle_transcription_json(request.body);
2529+
return handle_transcription_json(request.body, detail);
25052530
}
25062531

2507-
HttpResponse ServerState::handle_transcription_json(const std::string & body_text) {
2532+
HttpResponse ServerState::handle_transcription_json(const std::string & body_text, bool detail) {
25082533
const auto body = engine::io::json::parse(body_text);
25092534
auto & model = require_model(body);
25102535
const auto request = apply_default_request_options(
25112536
model,
25122537
build_openai_transcription_request(body, request_base_, model.accepts_language));
25132538
const auto busy_timeout_ms = parse_busy_timeout_override(body);
25142539
if (bool_field(body, "stream", false)) {
2540+
if (detail) {
2541+
return error_response(400, kDetailStreamUnsupported, "invalid_request_error");
2542+
}
25152543
return run_transcription_stream(model, request, busy_timeout_ms);
25162544
}
2517-
return run_transcription(model, request, busy_timeout_ms);
2545+
return run_transcription(model, request, busy_timeout_ms, detail);
25182546
}
25192547

25202548
// Accepts the same multipart/form-data shape OpenAI's Whisper API (and clients built against it,
25212549
// e.g. Open WebUI) send: a "file" part with the audio bytes, plus "model" and optional "language"
25222550
// fields. audio.cpp's native JSON request only takes a server-local path, so the uploaded bytes are
25232551
// spooled to a temp file and routed through the existing JSON request builder.
2524-
HttpResponse ServerState::handle_transcription_multipart(const std::string & body_text, const std::string & boundary) {
2552+
HttpResponse ServerState::handle_transcription_multipart(
2553+
const std::string & body_text, const std::string & boundary, bool detail) {
25252554
const auto parts = parse_multipart_body(body_text, boundary);
25262555
log_multipart_request_summary_if_enabled(config_, parts);
25272556

@@ -2591,15 +2620,19 @@ HttpResponse ServerState::handle_transcription_multipart(const std::string & bod
25912620
build_openai_transcription_request(
25922621
body, request_base_, model.accepts_language, &file_part->data));
25932622
if (stream) {
2623+
if (detail) {
2624+
return error_response(400, kDetailStreamUnsupported, "invalid_request_error");
2625+
}
25942626
return run_transcription_stream(model, request, busy_timeout_ms);
25952627
}
2596-
return run_transcription(model, request, busy_timeout_ms);
2628+
return run_transcription(model, request, busy_timeout_ms, detail);
25972629
}
25982630

25992631
HttpResponse ServerState::run_transcription(
26002632
LoadedModel & model,
26012633
const engine::runtime::TaskRequest & request,
2602-
std::optional<int> busy_timeout_ms) {
2634+
std::optional<int> busy_timeout_ms,
2635+
bool detail) {
26032636
const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming
26042637
? run_streaming_model(model, request, {}, busy_timeout_ms)
26052638
: run_model(model, request, busy_timeout_ms);
@@ -2610,9 +2643,31 @@ HttpResponse ServerState::run_transcription(
26102643
if (!request.audio_input.has_value()) {
26112644
throw std::runtime_error("transcription timing requires audio_input");
26122645
}
2613-
return json_response(
2614-
"{\"text\":" + json_quote(result.text_output->text) +
2615-
",\"timing\":" + timing_json(timed_result.wall_ms, *request.audio_input) + "}");
2646+
if (!detail) {
2647+
return json_response(
2648+
"{\"text\":" + json_quote(result.text_output->text) +
2649+
",\"timing\":" + timing_json(timed_result.wall_ms, *request.audio_input) + "}");
2650+
}
2651+
// Models that align words or separate speakers report them through the same
2652+
// detail fields /v1/tasks/run serialises, and the transcription response
2653+
// discards them. This is the opt-in route that keeps them, so the shape stays
2654+
// a superset of the plain one: text first, timing last, details in between.
2655+
std::ostringstream out;
2656+
out << "{\"text\":" << json_quote(result.text_output->text);
2657+
if (!result.text_output->language.empty()) {
2658+
out << ",\"language\":" << json_quote(result.text_output->language);
2659+
}
2660+
write_transcript_detail_fields(out, result, [&](const std::string & name) {
2661+
out << "," << json_quote(name) << ":";
2662+
});
2663+
// Detail spans are sample offsets, so the rate they are counted in has to
2664+
// travel with them or a client cannot turn them into timestamps.
2665+
if (!result.speech_segments.empty() || !result.speaker_turns.empty() ||
2666+
!result.word_timestamps.empty()) {
2667+
out << ",\"sample_rate\":" << request.audio_input->sample_rate;
2668+
}
2669+
out << ",\"timing\":" << timing_json(timed_result.wall_ms, *request.audio_input) << "}";
2670+
return json_response(out.str());
26162671
}
26172672

26182673
HttpResponse ServerState::run_transcription_stream(

app/server/runtime.h

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,17 @@ class ServerState final : public IHttpHandler {
172172
const engine::runtime::TaskRequest & request,
173173
const engine::io::json::Value & body);
174174
HttpResponse handle_speech_live(const HttpRequest & request);
175-
HttpResponse handle_transcription(const HttpRequest & request);
176-
HttpResponse handle_transcription_json(const std::string & body_text);
177-
HttpResponse handle_transcription_multipart(const std::string & body_text, const std::string & boundary);
175+
// detail selects the /v1/audio/transcriptions/details response, which adds the
176+
// segment, speaker-turn and word arrays the plain route drops.
177+
HttpResponse handle_transcription(const HttpRequest & request, bool detail = false);
178+
HttpResponse handle_transcription_json(const std::string & body_text, bool detail = false);
179+
HttpResponse handle_transcription_multipart(
180+
const std::string & body_text, const std::string & boundary, bool detail = false);
178181
HttpResponse run_transcription(
179182
LoadedModel & model,
180183
const engine::runtime::TaskRequest & request,
181-
std::optional<int> busy_timeout_ms = std::nullopt);
184+
std::optional<int> busy_timeout_ms = std::nullopt,
185+
bool detail = false);
182186
HttpResponse run_transcription_stream(
183187
LoadedModel & model,
184188
const engine::runtime::TaskRequest & request,

0 commit comments

Comments
 (0)