Skip to content

Support OpenAI verbose_json for /v1/audio/transcriptions (+ multipart field fix) - #510

Open
ademasi wants to merge 5 commits into
ROCm:mainfrom
ademasi:upstream/whisper-verbose-json
Open

Support OpenAI verbose_json for /v1/audio/transcriptions (+ multipart field fix)#510
ademasi wants to merge 5 commits into
ROCm:mainfrom
ademasi:upstream/whisper-verbose-json

Conversation

@ademasi

@ademasi ademasi commented Apr 23, 2026

Copy link
Copy Markdown

Support OpenAI verbose_json for /v1/audio/transcriptions (+ multipart field fix)

Summary

  • Adds support for response_format=verbose_json to POST /v1/audio/transcriptions, returning an OpenAI-compatible payload with per-segment timestamps (start / end) extracted from Whisper's internal <|X.XX|> markers.
  • Fixes a multipart-dispatcher bug where everything except model and file was silently dropped on the way to the transcription handler (so response_format, language, prompt, stream, and temperature had no effect when sent via multipart).
  • Fully backward-compatible: callers that don't ask for verbose_json (or don't set response_format at all) get the same {model, text} payload shape as before.

Changes

  • src/server/server.cpp — the multipart handler for /v1/audio/transcriptions now forwards response_format, language, prompt, stream, and temperature from the parsed multipart body into the request JSON before dispatching to handle_openai_audio_transcriptions. Previously only model and file were forwarded.
  • src/server/rest_handler.cpphandle_openai_audio_transcriptions now reads response_format from the request. When it is verbose_json, the Whisper engine is invoked with timestamps enabled and the raw output is split on <\|(\d+\.\d+)\|> to build a segments array; otherwise the existing {model, text} behavior is preserved (with timestamp markers stripped as a defensive step). Adds #include <regex>.

Before / after

Before

Request (either JSON body or multipart):

POST /v1/audio/transcriptions
Content-Type: multipart/form-data

model=whisper-...
file=<audio>
response_format=verbose_json

Response (every format returned the same shape; response_format was ignored by the multipart path entirely):

{
  "model": "whisper-...",
  "text": "This is the transcribed text of the audio clip..."
}

After

Same request with response_format=verbose_json:

{
  "task": "transcribe",
  "language": "en",
  "duration": 12.34,
  "text": "This is the transcribed text of the audio clip split into two segments.",
  "segments": [
    {
      "id": 0,
      "seek": 0,
      "start": 0.00,
      "end": 6.20,
      "text": " This is the transcribed text of the audio",
      "tokens": [],
      "temperature": 0.0,
      "avg_logprob": 0.0,
      "compression_ratio": 0.0,
      "no_speech_prob": 0.0
    },
    {
      "id": 1,
      "seek": 0,
      "start": 6.20,
      "end": 12.34,
      "text": " clip split into two segments.",
      "tokens": [],
      "temperature": 0.0,
      "avg_logprob": 0.0,
      "compression_ratio": 0.0,
      "no_speech_prob": 0.0
    }
  ],
  "model": "whisper-..."
}

Same request with response_format=json (or omitted): byte-for-byte identical to the old behavior, i.e. {model, text}.

Backward compatibility

  • Plain response_format=json callers (and callers that omit response_format) see the same {model, text} response shape as before. The only change to that path is that any stray <|X.XX|> timestamp markers are stripped from text before returning — but because the underlying generator is only asked to emit timestamps when verbose_json is requested, this strip is a belt-and-suspenders pass and is a no-op for the plain path.
  • The multipart fix is additive: fields that callers weren't previously setting via multipart continue to use the handler's defaults; fields that callers were setting (but which were being dropped) now actually take effect. Anyone relying on the old "dropped" behavior would have had to be setting these fields via a JSON body instead, which already worked correctly.
  • No changes to URL, method, required fields, or the shape of any pre-existing response format.

Test plan

  • Tested end-to-end against a long-form (~1 hour) multi-speaker Opus recording; response_format=verbose_json returned 200+ segments with monotonically increasing start / end timestamps and a matching flat text field.
  • Verified the duration field in the verbose response matches the expected clip length (within one segment boundary).
  • Plain response_format=json path manually verified to return {model, text} with no behavioral change and no timestamp markers in text.
  • Verified that language, prompt, temperature, and stream sent via multipart now reach the handler (previously dropped).
  • CI build on maintainer's platform matrix.

Notes for reviewers

  • Timestamp parsing is regex-based: segment boundaries are extracted from Whisper's internal <|X.XX|> markers using std::regex with the pattern <\|(\d+\.\d+)\|>. This is intentionally conservative — it assumes the markers are emitted verbatim in the decoded string and can't straddle a Unicode boundary. In practice this holds today because the markers are ASCII, but it is fragile if the tokenizer or decoder output format changes. A more robust long-term fix would be to do the split at the tokenizer level (i.e. have whisper_engine->generate(...) return structured segment data directly, rather than reconstructing it from the flat string), which is out of scope for this PR.
  • The per-segment tokens, avg_logprob, compression_ratio, and no_speech_prob fields are emitted with zero/empty defaults because the Whisper engine doesn't currently surface them; they are present in the response so the shape matches the OpenAI spec and downstream clients don't have to special-case missing keys. Filling them in is a follow-up.
  • The two commits are split so the multipart-dispatcher bug fix (fix(server): forward multipart audio fields...) can be taken independently of the verbose_json feature commit if desired.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds OpenAI-compatible response_format=verbose_json output to /v1/audio/transcriptions and fixes multipart forwarding so non-file/model fields reach the transcription handler.

Changes:

  • Forward additional multipart fields (response_format, language, prompt, stream, temperature) into the transcription request JSON.
  • Add verbose_json response mode that parses Whisper timestamp markers into OpenAI-style segments, while preserving the existing {model, text} response for default/json mode.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/server/server.cpp Forwards more multipart form fields into the transcription request payload.
src/server/rest_handler.cpp Implements verbose_json formatting by extracting segments from `<

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/server/server.cpp Outdated
if (parts.count("language")) request_json["language"] = parts["language"].content;
if (parts.count("prompt")) request_json["prompt"] = parts["prompt"].content;
if (parts.count("stream")) request_json["stream"] = (parts["stream"].content == "true");
if (parts.count("temperature")) request_json["temperature"] = std::stof(parts["temperature"].content);

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

temperature is parsed with std::stof(parts["temperature"].content) directly in the request handler. If the multipart field is present but empty/invalid (or uses a locale-specific format), this throws and is caught by the generic handler exception path, returning a 500 instead of a client error. Consider validating/parsing defensively (e.g., try/catch and return a structured 400 error, or ignore invalid values and let the handler apply defaults).

Suggested change
if (parts.count("temperature")) request_json["temperature"] = std::stof(parts["temperature"].content);
if (parts.count("temperature")) {
const std::string& temperature_content = parts["temperature"].content;
std::istringstream temperature_stream(temperature_content);
temperature_stream.imbue(std::locale::classic());
float temperature = 0.0f;
char extra = '\0';
if (!(temperature_stream >> temperature) || (temperature_stream >> extra)) {
json error_response = {
{"error", {
{"message", "Invalid temperature: expected a numeric value."},
{"type", "invalid_request_error"},
{"param", "temperature"}
}}
};
send_response(error_response);
return;
}
request_json["temperature"] = temperature;
}

Copilot uses AI. Check for mistakes.
Comment on lines 1069 to 1073
#else
throw std::runtime_error("ASR models are not supported in this build");
std::string audio_context;
std::string raw_output;
std::string language;
#endif

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the FASTFLOWLM_LINUX_LIMITED_MODELS branch, throw std::runtime_error(...) is followed by declarations of raw_output / language. This code is unreachable and can trigger compiler warnings (and break builds when warnings are treated as errors). Move the declarations before the throw (or remove them entirely and restructure the conditional so the variables exist in both branches without unreachable statements).

Copilot uses AI. Check for mistakes.
Comment on lines +1077 to +1128
if (want_verbose) {
std::vector<std::tuple<float, size_t, size_t>> markers;
auto it = std::sregex_iterator(raw_output.begin(), raw_output.end(), ts_regex);
auto end = std::sregex_iterator();
for (; it != end; ++it) {
markers.emplace_back(
std::stof((*it)[1].str()),
static_cast<size_t>(it->position()),
static_cast<size_t>(it->position() + it->length()));
}

json segments = json::array();
std::string plain_text;
float max_end = 0.0f;
for (size_t i = 0; i + 1 < markers.size(); ++i) {
float seg_start = std::get<0>(markers[i]);
float seg_end = std::get<0>(markers[i + 1]);
size_t text_begin = std::get<2>(markers[i]);
size_t text_end = std::get<1>(markers[i + 1]);
if (text_end < text_begin) continue;
std::string seg_text = raw_output.substr(text_begin, text_end - text_begin);
size_t a = seg_text.find_first_not_of(" \t\n\r");
if (a == std::string::npos) continue;
size_t b = seg_text.find_last_not_of(" \t\n\r");
seg_text = seg_text.substr(a, b - a + 1);
if (seg_text.empty()) continue;

segments.push_back({
{"id", (int)segments.size()},
{"seek", 0},
{"start", seg_start},
{"end", seg_end},
{"text", std::string(" ") + seg_text},
{"tokens", json::array()},
{"temperature", 0.0},
{"avg_logprob", 0.0},
{"compression_ratio", 0.0},
{"no_speech_prob", 0.0}
});
if (!plain_text.empty()) plain_text += " ";
plain_text += seg_text;
if (seg_end > max_end) max_end = seg_end;
}

response = {
{"task", "transcribe"},
{"language", language},
{"duration", max_end},
{"text", plain_text},
{"segments", segments},
{"model", model}
};

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verbose_json segmentation assumes there are at least 2 timestamp markers; if markers.size() < 2, the loop never runs and the response returns empty text, empty segments, and duration stays 0. This can happen for very short outputs where the model emits only the initial timestamp marker before EOS. Add a fallback for insufficient markers (e.g., strip markers and return a single segment or fall back to the non-verbose {model,text} response) so verbose_json never drops transcription text.

Copilot uses AI. Check for mistakes.
@ademasi
ademasi force-pushed the upstream/whisper-verbose-json branch from d29b268 to 55435e8 Compare May 7, 2026 13:13
ademasi and others added 5 commits July 5, 2026 17:32
The multipart dispatcher for POST /v1/audio/transcriptions was only
forwarding `model` and `file` from the parsed multipart body to
`handle_openai_audio_transcriptions`, silently dropping every other
OpenAI-spec field: `response_format`, `language`, `prompt`, `stream`,
and `temperature`.

This meant callers who sent, e.g., `response_format=verbose_json` via
multipart (which is the canonical way per the OpenAI audio API) would
always get the default `json` response regardless of what they asked
for, and similar for language hints, prompts, streaming, and
temperature.

Forward all of these from the parsed `parts` map into the request JSON
before dispatching. `stream` is parsed as a boolean and `temperature`
as a float to match the JSON-body code path in the handler. Fields not
present in the multipart body are simply omitted, preserving the
existing defaults in the handler.

This is a bug fix independent of any response-format features.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a client calls `POST /v1/audio/transcriptions` with
`response_format=verbose_json`, the handler now returns an
OpenAI-compatible payload of the form:

    {
      "task": "transcribe",
      "language": "<detected>",
      "duration": <seconds>,
      "text": "<flat transcript>",
      "segments": [
        {"id", "seek", "start", "end", "text",
         "tokens", "temperature", "avg_logprob",
         "compression_ratio", "no_speech_prob"},
        ...
      ],
      "model": "<model-id>"
    }

Per-segment `start` / `end` timestamps are parsed from the Whisper
engine's internal `<|X.XX|>` markers by enabling the timestamp flag on
`whisper_engine->generate(...)` when verbose output is requested, then
splitting the raw output on a `<\|(\d+\.\d+)\|>` regex. Consecutive
markers bound each segment; whitespace-only or empty segments are
dropped. `duration` is the maximum end-time across emitted segments.

The plain `json` format is unchanged on the wire and remains
backward-compatible: the handler still returns `{model, text}`, but
the `text` field now has any stray timestamp markers stripped (the
underlying generator is only asked for timestamps when
`verbose_json` is requested, so the strip is a belt-and-suspenders
pass).

Motivation: enables downstream tools (diarization pipelines, subtitle
generators, alignment tools) to align transcript text with audio
timecodes via the standard OpenAI API surface, instead of requiring
a FastFlowLM-specific endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously, `std::stof(parts["temperature"].content)` was called
unconditionally when the multipart body contained a `temperature`
field. If the value was empty, non-numeric, or in a locale-specific
format, this threw `std::invalid_argument` / `std::out_of_range`,
which was caught by the generic exception handler downstream and
surfaced to the client as a 500 — masking what was really a malformed
client request.

Wrap the parse in a try/catch and drop the field on parse failure,
mirroring the way `language` and `prompt` silently fall through to
the handler's defaults when absent. The handler then applies its
default `temperature`, the request succeeds, and we no longer 500
on malformed input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `FASTFLOWLM_LINUX_LIMITED_MODELS` branch had `throw std::runtime_error(...)`
followed by declarations of `raw_output` and `language`. Those declarations
were unreachable: `throw` exits the function before they execute, and they
existed only to satisfy the post-`#endif` code that referenced the variables.

Move the declarations above the `#ifdef` so both branches share the same
single declaration. The non-LIMITED branch assigns into the hoisted
variables; the LIMITED branch throws unconditionally. No behavioral change,
removes the unreachable-code smell flagged by review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The original verbose_json builder iterates over consecutive marker
pairs to bound segments, so it requires at least two `<|X.XX|>`
markers in the Whisper output. Very short clips (or any case where
the model emits only the initial timestamp before EOS) produce
fewer than two markers, and previously this returned an empty
`segments: []`, an empty `text`, and `duration: 0` — silently
dropping the transcript even though `raw_output` had content.

After the segmentation loop, if `segments` is still empty, emit a
single fallback segment containing the full stripped transcript.
Its `start` defaults to the first available marker (or 0 when no
markers exist) and `end` defaults to the last available marker (or
0). `duration` is updated accordingly. If the trimmed transcript
is empty as well, no fallback is emitted — `segments` and `text`
genuinely have nothing to surface.

This guarantees verbose_json never returns less information than
the plain `json` path on the same input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ademasi
ademasi force-pushed the upstream/whisper-verbose-json branch from 55435e8 to 17ad979 Compare July 5, 2026 15:33
@ademasi

ademasi commented Jul 5, 2026

Copy link
Copy Markdown
Author

Hi FastFlowLM team 👋

Friendly nudge on this one. I've rebased the branch onto the latest main (v0.9.43) — it's currently mergeable — and addressed all three points from the Copilot review:

  • Defensive temperature parsing in the multipart dispatch, so a malformed value no longer throws a 500; it's dropped and the handler applies its default.
  • Hoisted the raw_output/language declarations above the FASTFLOWLM_LINUX_LIMITED_MODELS #ifdef to remove the unreachable-code path.
  • Fallback single segment when Whisper emits fewer than two <|timestamp|> markers, so verbose_json never drops text on very short clips.

Quick recap of what the PR does:

  • Adds response_format=verbose_json to POST /v1/audio/transcriptions, returning an OpenAI-compatible payload with per-segment start/end timestamps (parsed from Whisper's <|X.XX|> markers), plus top-level task/language/duration.
  • Fixes a multipart bug where every field except model and file was silently dropped, so response_format, language, prompt, stream, and temperature had no effect when sent over multipart.
  • Fully backward-compatible: the default json path is unchanged ({model, text}).

I've been running it locally against long multi-speaker recordings to feed a diarization pipeline, and having the timestamps over the standard OpenAI API surface makes alignment trivial.

Would you be open to merging this? Happy to make any changes you'd like — for instance, if you'd prefer the segment split done at the tokenizer level rather than via regex on the decoded string, I'm glad to rework it that way. Thanks for the great project!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants