Skip to content

feat(server): honour response_format in /v1/audio/transcriptions (stacked on #794) - #811

Closed
tawei-amd wants to merge 10 commits into
mainfrom
fix/whisper-response-format
Closed

tawei-amd wants to merge 10 commits into
mainfrom
fix/whisper-response-format

Conversation

@tawei-amd

Copy link
Copy Markdown
Collaborator

Stacked on #794. This branch is #794's head (f4f9341f) plus two commits; review only those. When #794 merges, this reduces to them. It targets main so the ASR test and model sweep run.

What

/v1/audio/transcriptions dropped response_format, so every request got {model, text}:

  • verbose_json had no segments, language or duration.
  • text, srt and vtt came back as JSON.
  • The ASR test's verbose_json and timestamp_rules groups had nothing to check. Their warning was server ignores verbose_json.

cdc8299: feat(server): honour response_format

  • verbose_json, srt, vtt: decode with timestamps returned, then split the output into segments (src/include/whisper/transcript_format.hpp).
    • A segment's text runs from the timestamp before it to the one after it, or to the end of the clip when a window cut it off. Times are clamped to the clip.
    • A timestamp can fall inside a multi-byte character, since tokens are bytes. Those bytes move to the next segment so the JSON stays valid UTF-8.
  • verbose_json fields:
    • duration is the clip length.
    • language is lowercase, as OpenAI reports it (english).
    • tokens, avg_logprob, compression_ratio and no_speech_prob are placeholders that no client filter drops a segment on.
  • text, srt, vtt are sent as text/plain; charset=utf-8: send_response now writes a bare JSON string as a plain-text body.
  • json and the default are unchanged.
  • Bad input: an unknown response_format or language is now a 400 invalid_request_error instead of a 500 server_error. The OpenAI SDKs retry a 500.
  • asr_test.py now expects OpenAI's lowercase language names.

871ddcf: ci(asr): require verbose_json and timestamp_rules

The workflow comment asks for exactly this once the server passes these groups.

Tested

Strix NPU, Linux XRT, local build of this branch:

  • asr_test.py with every group required: all checks pass on the 84 files.
    • verbose_json 8/8, timestamp_rules 2/2, language 13/13 (reported as english, japanese, …; xx is rejected with 400), prompt 2/2, window_tail 4/4, quiet_log 1/1.
    • Pooled WER 7.32%.
    • 79/84 transcripts are identical to fix(whisper): make the ASR decode loop behave like Whisper #794's CI run. The other five differ by one-word decode flips.
  • curl:
    • text, srt and vtt return text/plain bodies; the Japanese SRT is valid UTF-8.
    • response_format=xml and language=xx return HTTP 400.
    • No transcript appears in the server log.
  • Unit checks of transcript_format.hpp:
    • timestamp pairs and single timestamps
    • a window cut off with no closing timestamp
    • clamping to the clip
    • <| inside speech
    • a timestamp splitting a 3-byte character
    • SRT/VTT clock formatting

Not in this PR

timestamp_granularities[]=word, translation (/v1/audio/translations) and streaming.

🤖 Generated with Claude Code

quine00 and others added 10 commits October 17, 2026 06:35
Whisper::generate forced the next token into the timestamp range after
16 tokens without one. The model then treats the forced timestamp as the
end of the segment and often emits EOT, so sentences longer than ~16
tokens are cut mid-word ("...has count <|26.20|>").

On 82 English clips (<= 30 s) through flm serve, same NPU, libs and
weights, this one change takes WER from 7.2% to 3.1%; the drop is almost
all deletions (82 -> 13). Whisper emits timestamps on its own when the
prompt asks for them; nothing needs to force them.
generate() sampled the language from the <|startoftranscript|> logits
but never fed it back, so the decoder saw <|startoftranscript|>
<|transcribe|>, a prompt Whisper was not trained on. Feed the language
token before the task token, as Whisper expects.

The translate branch was empty and relied on the loop feeding the
language token; it now feeds <|translate|> the same way transcribe
feeds <|transcribe|>.
The handler read only model and file, so OpenAI clients that set
language or prompt got neither. Forward both to Whisper::generate:

- language (ISO 639-1) replaces per-window detection with the given
  language token; an unknown code is an error, not a silent fallback.
- prompt goes before <|startoftranscript|> behind <|startofprev|>, as
  openai-whisper does: leading space, text tokens only, the last 223
  tokens kept. The decode budget shrinks by the prompt length.

Both are optional; requests without them behave as before.
The timestamp loop sampled plain argmax with no rules, and its time map
missed <|30.00|>:

- _build_time_map summed 0.02f while <= 30.00. Float error ends the
  loop at 29.98, so <|30.00|> (51865) was not a timestamp: never
  sampled as one, never used to seek. Build the 1501 entries from an
  integer index.
- No pairing rule, so text could follow a closing timestamp, and no
  limit on the first timestamp, so a window starting mid-speech could
  open at <|9.80|> and skip what came before.

Apply openai-whisper's ApplyTimestampRules: the first timestamp of a
window is at most <|1.00|>; after a timestamp that closes a segment
only a timestamp or EOT may follow; a segment is not empty; and
timestamps never go back. Making <|30.00|> a timestamp without these
rules made long audio worse (see the PR), because a window that ends
on it then seeks a full 30 s and the next window may skip its start.
For audio over 30 s, generate() moves the next window to the last
timestamp it saw. Text emitted after that timestamp was already in the
result, and the next window decodes the same audio again, so the tail
was duplicated. openai-whisper and faster-whisper drop the unfinished
segment before seeking.

Hold text after a timestamp in a full window until the next timestamp
closes it, and drop it if none does. When a window has no timestamp to
resume from, keep the text and skip 30 s as before. Also reset
last_time_stamp per window, so a window without timestamps no longer
seeks by the previous window's last one.

Windows shorter than 30 s, and the no-timestamp mode, stream tokens as
before.
/v1/audio/transcriptions printed every transcript to stdout ("Audio
content: ..."). Under a service manager that is the journal, so
dictated speech ends up in system logs. Log it only when
FLM_LOG_RAW_OUTPUT is set, the knob that already re-enables raw model
output for other models.
In a full window, text after the last timestamp is held and dropped,
since the next window starts at that timestamp and decodes it again.
The timestamp that opened the dropped segment was still emitted, so
with return_time_stamp the result had a segment with no text and the
same timestamp three times in a row:

  ...from Linux.<|18.50|><|18.50|><|18.50|> and I saw...

Hold the opening timestamp (the second of a pair) with the text, emit
both when a timestamp closes the segment, and drop both otherwise.
On a 110 s clip the repeated timestamps are gone and the transcript
text is unchanged.

Co-Authored-By: Claude <[email protected]>
The ASR test's prompt checks failed on every platform: a prompt that repeats
words from the audio ("China's", "He", "Don't") dropped the start of the
transcript. The prompt construction in this PR matches openai-whisper; the
engine was the problem. Rebuilt libwhisper_npu (Linux and Windows, HRX and
XRT) and the five Whisper xclbins from FastFlowLM_IRON caee8bcd, which:

- stops encoder self-attention and decoder cross-attention from attending to
  the 36 padding rows after the 1500 audio frames (host side and a key mask
  in the encoder attention kernel);
- sets round-to-nearest in the Whisper kernels; they ran with the reset
  default, floor, which biased the bfp16-emulated matmuls (encoder fc2 was
  10.6% off);
- uses the exact GELU instead of the tanh approximation;
- keeps the encoder and decoder residual stream in fp32, where bf16 rounded
  small layer updates away next to outlier channels.

The model weights are unchanged. On the Linux HRX portable build, the first-
word logits now match an fp32 run of the same 4-bit weights, and
asr_test.py --require prompt passes: WER 2.44% with a prompt and without,
names 10/10 (was 41.46% vs 4.88%, 8/10).

Co-Authored-By: Claude <[email protected]>
The server dropped response_format, so every request got {model, text}:
verbose_json had no segments, language or duration, and text, srt and vtt
came back as JSON. The ASR test's verbose_json and timestamp_rules checks
had nothing to run on.

verbose_json, srt and vtt decode with the timestamps returned and split the
output into segments (transcript_format.hpp): text runs from the timestamp
before it to the one after it, or to the end of the clip when a window cut
it off, clamped to the clip. A timestamp can fall inside a multi-byte
character, so those bytes move to the next segment. verbose_json reports
the clip's duration and, as OpenAI, the language in lower case; tokens and
the per-segment statistics are placeholders. text, srt and vtt go out as
text/plain. json and the default are unchanged.

An unknown response_format or language is a 400 invalid_request_error, not
a 500 server_error: the OpenAI SDKs retry a 500.

On a Strix NPU (XRT), asr_test.py with every group required passes all 84
files: verbose_json 8/8, timestamp_rules 2/2, language 13/13.

Co-Authored-By: Claude <[email protected]>
The server now returns verbose_json segments, so a pull request that breaks
them, or Whisper's timestamp rules, should fail.

Co-Authored-By: Claude <[email protected]>
@tawei-amd

Copy link
Copy Markdown
Collaborator Author

Superseded by #794: both commits (cdc8299 feat(server): honour response_format in /v1/audio/transcriptions and 871ddcf ci(asr): require verbose_json and timestamp_rules) were pushed onto #794's branch, unchanged. Review and CI continue there. This PR's ASR test passed on all four platforms (verbose_json 8/8, timestamp_rules 2/2, no warnings).

@tawei-amd tawei-amd closed this Oct 18, 2026
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