Skip to content

Commit edc1ab9

Browse files
[Frontend][Voxtral] Add opt-in segment timestamps to /v1/realtime
The realtime WebSocket API streams transcription text with no timing information, so clients doing diarization, subtitling or alignment have to guess word timing from message arrival (#39735). Voxtral realtime emits one token per 80 ms audio frame, so an output token's index is an index into the audio, and `[STREAMING_WORD]` marks where an emission group ends. Clients opt in per connection with `timestamp_granularities: ["segment"]` on `session.update` and get a `segments` array of `{text, end}` on `transcription.delta` and `transcription.done`. Alignment: end_s(k) = (len(prompt_tokens) - n_pad_frames - n_delay + k) * 0.08 The left pad is audio and the delay tokens are not, so both must come out of the prompt length; the formula proposed on the issue subtracts only the delay and lands every timestamp 2.56 s late. The pad is derived from the pad audio rather than from `n_left_pad_tokens` - mistral-common builds one from the other today, and a change that decoupled them would otherwise silently shift every timestamp - and mismatches raise instead. Segments are emission groups, not words: the model omits `[STREAMING_WORD]` for words sharing an emission frame by design (arXiv 2602.11298 sections 3.1 and 6.2), and the marker precedes the subwords of its own group, so a boundary closes the segment accumulated since the previous boundary. Segmentation is on token ids, not delta text, because the boundary token decodes to the empty string under `skip_special_tokens`. Also adds the `session.updated` acknowledgement, which makes the opt-in verifiable instead of silent, and replaces three tests' 5 s waits for an event the server never sent. Models opt in with `supports_realtime_segment_timestamps`; Qwen3-ASR realtime inherits `False` since it re-prompts per segment and has no token-per-frame lockstep. Clients that do not opt in see byte-identical payloads: the `segments` key is omitted entirely. Rejected at negotiation, each naming problem, cause and fix: a model that cannot do it, a model advertising support it has not implemented, `word` granularity, a tokenizer without the marker, and `--stream-interval > 1`. That last one is not a timestamp limitation - batching withholds an output until `stream-interval` tokens accumulate, but the lockstep cannot produce the next token until the previous one is streamed back, so realtime stalls whether or not timestamps were requested. The error says so rather than suggesting the opt-in be dropped. Signed-off-by: Andrii Pasternak <andriipasternak31@gmail.com>
1 parent 0055b8b commit edc1ab9

8 files changed

Lines changed: 1060 additions & 65 deletions

File tree

docs/serving/online_serving/speech_to_text.md

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,8 @@ Audio must be sent as base64-encoded PCM16 audio at 16kHz sample rate, mono chan
181181

182182
1. Client connects to `ws://host/v1/realtime`
183183
2. Server sends `session.created` event
184-
3. Client optionally sends `session.update` with model/params
184+
3. Client optionally sends `session.update` with model/params, and the server
185+
acknowledges with `session.updated`
185186
4. Client sends `input_audio_buffer.commit` when ready
186187
5. Client sends `input_audio_buffer.append` events with base64 PCM16 chunks
187188
6. Server sends `transcription.delta` events with incremental text
@@ -196,17 +197,61 @@ Audio must be sent as base64-encoded PCM16 audio at 16kHz sample rate, mono chan
196197
| ----- | ----------- |
197198
| `input_audio_buffer.append` | Send base64-encoded audio chunk: `{"type": "input_audio_buffer.append", "audio": "<base64>"}` |
198199
| `input_audio_buffer.commit` | Trigger transcription processing or end: `{"type": "input_audio_buffer.commit", "final": bool}` |
199-
| `session.update` | Configure session: `{"type": "session.update", "model": "model-name"}` |
200+
| `session.update` | Configure session: `{"type": "session.update", "model": "model-name", "timestamp_granularities": ["segment"]}` |
200201

201202
### Server → Client Events
202203

203204
| Event | Description |
204205
| ----- | ----------- |
205206
| `session.created` | Connection established with session ID and timestamp |
207+
| `session.updated` | Acknowledgement of `session.update`, echoing the configuration that took effect |
206208
| `transcription.delta` | Incremental transcription text: `{"type": "transcription.delta", "delta": "text"}` |
207209
| `transcription.done` | Final transcription with usage stats |
208210
| `error` | Error notification with message and optional code |
209211

212+
#### Segment-level timestamps
213+
214+
Models that emit one output token per audio frame can report when each piece
215+
of transcribed audio ended. Opt in per connection:
216+
217+
```json
218+
{"type": "session.update", "model": "mistralai/Voxtral-Mini-4B-Realtime-2602", "timestamp_granularities": ["segment"]}
219+
```
220+
221+
If the request was honoured the server replies with `session.updated`, echoing
222+
`"timestamp_granularities": ["segment"]`. If it cannot be, the server replies
223+
with `error` instead and the session stays unconfigured, so the opt-in is
224+
never silently dropped. Once enabled, `transcription.delta` and
225+
`transcription.done` carry a `segments` array:
226+
227+
```json
228+
{"type": "transcription.done", "text": " Mary had a little lamb", "segments": [{"text": " Mary had", "end": 1.36}, {"text": " a little lamb", "end": 2.08}]}
229+
```
230+
231+
Points worth knowing before building on this:
232+
233+
- **End only.** The model marks where a group of audio ends, not where it
234+
starts. Deriving a start from the previous `end` is wrong across pauses.
235+
- **Segments are emission groups, not words.** The model may commit several
236+
words at once, and then they share one entry. Expect at most one entry per
237+
spoken word.
238+
- **Granularity is one audio frame**, 80 ms for Voxtral realtime.
239+
- **The clock is relative to the utterance**, and restarts on every non-final
240+
`input_audio_buffer.commit`.
241+
- **Timestamps trail their text by one event.** The boundary marker itself
242+
decodes to no text, so a delta carrying `segments` has an empty `delta`. A
243+
client that skips events with empty text will drop them.
244+
- `transcription.done` repeats every segment of the utterance, including the
245+
trailing one, which no delta can carry because generation ends first.
246+
- Requires `--stream-interval 1` (the default), and the opt-in is rejected
247+
otherwise. Batched streaming stalls realtime transcription entirely - the
248+
server withholds an output until `stream-interval` tokens accumulate, but
249+
the next token cannot be generated until the previous one is fed back - so
250+
omitting `timestamp_granularities` is not a way to keep such a server
251+
usable.
252+
- Clients that do not opt in see byte-identical payloads: the `segments` key
253+
is omitted entirely.
254+
210255
#### Example Clients
211256

212257
- [openai_realtime_client.py](https://github.com/vllm-project/vllm/tree/main/examples/speech_to_text/realtime/openai_realtime_client.py) - Upload and transcribe an audio file

examples/speech_to_text/realtime/openai_realtime_client.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,13 @@ def audio_to_pcm16_base64(audio_path: str) -> str:
4545
return base64.b64encode(pcm16.tobytes()).decode("utf-8")
4646

4747

48-
async def realtime_transcribe(audio_path: str, host: str, port: int, model: str):
48+
async def realtime_transcribe(
49+
audio_path: str,
50+
host: str,
51+
port: int,
52+
model: str,
53+
segment_timestamps: bool = False,
54+
):
4955
"""
5056
Connect to the Realtime API and transcribe an audio file.
5157
"""
@@ -60,8 +66,21 @@ async def realtime_transcribe(audio_path: str, host: str, port: int, model: str)
6066
print(f"Unexpected response: {response}")
6167
return
6268

63-
# Validate model
64-
await ws.send(json.dumps({"type": "session.update", "model": model}))
69+
# Validate model, and opt in to segment timestamps if asked
70+
session_update = {"type": "session.update", "model": model}
71+
if segment_timestamps:
72+
session_update["timestamp_granularities"] = ["segment"]
73+
await ws.send(json.dumps(session_update))
74+
75+
# The server echoes the configuration that took effect, so a request
76+
# the model cannot honour is never silently dropped.
77+
response = json.loads(await ws.recv())
78+
if response["type"] == "error":
79+
print(f"Session update rejected: {response['error']}")
80+
return
81+
if segment_timestamps and not response.get("timestamp_granularities"):
82+
print("Server did not enable segment timestamps.")
83+
return
6584

6685
# Signal ready to start
6786
await ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
@@ -99,6 +118,10 @@ async def realtime_transcribe(audio_path: str, host: str, port: int, model: str)
99118
print(response["delta"], end="", flush=True)
100119
elif response["type"] == "transcription.done":
101120
print(f"\n\nFinal transcription: {response['text']}")
121+
for segment in response.get("segments") or []:
122+
# End times only: the model marks where a group of audio
123+
# ends, not where it starts.
124+
print(f" ends at {segment['end']:>7.2f}s {segment['text']!r}")
102125
if response.get("usage"):
103126
print(f"Usage: {response['usage']}")
104127
break
@@ -115,7 +138,11 @@ def main(args):
115138
audio_path = str(AudioAsset("mary_had_lamb").get_local_path())
116139
print(f"No audio path provided, using default: {audio_path}")
117140

118-
asyncio.run(realtime_transcribe(audio_path, args.host, args.port, args.model))
141+
asyncio.run(
142+
realtime_transcribe(
143+
audio_path, args.host, args.port, args.model, args.segment_timestamps
144+
)
145+
)
119146

120147

121148
if __name__ == "__main__":
@@ -134,6 +161,11 @@ def main(args):
134161
default=None,
135162
help="Path to the audio file to transcribe.",
136163
)
164+
parser.add_argument(
165+
"--segment-timestamps",
166+
action="store_true",
167+
help="Ask the server for the end time of each transcribed segment.",
168+
)
137169
parser.add_argument(
138170
"--host",
139171
type=str,

tests/entrypoints/speech_to_text/realtime/test_realtime_validation.py

Lines changed: 138 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
import asyncio
55
import json
6-
import warnings
76

87
import numpy as np
98
import pybase64 as base64
@@ -70,6 +69,138 @@ def mary_had_lamb_audio_chunks() -> list[str]:
7069
return chunks
7170

7271

72+
async def _start_session(
73+
ws, model_name: str, timestamp_granularities: list[str] | None = None
74+
) -> dict:
75+
"""Open a session and return the session.updated acknowledgement."""
76+
event = await receive_event(ws, timeout=30.0)
77+
assert event["type"] == "session.created"
78+
79+
update: dict = {"type": "session.update", "model": model_name}
80+
if timestamp_granularities is not None:
81+
update["timestamp_granularities"] = timestamp_granularities
82+
await send_event(ws, update)
83+
84+
event = await receive_event(ws, timeout=10.0)
85+
assert event["type"] == "session.updated"
86+
return event
87+
88+
89+
async def _stream_utterance(ws, chunks: list[str], timeout: float = 60.0) -> list[dict]:
90+
"""Stream one utterance and return every event up to transcription.done."""
91+
await send_event(ws, {"type": "input_audio_buffer.commit"})
92+
for chunk in chunks:
93+
await send_event(ws, {"type": "input_audio_buffer.append", "audio": chunk})
94+
await send_event(ws, {"type": "input_audio_buffer.commit", "final": True})
95+
96+
events = []
97+
while True:
98+
event = await receive_event(ws, timeout=timeout)
99+
if event["type"] == "error":
100+
pytest.fail(f"Received error: {event}")
101+
events.append(event)
102+
if event["type"] == "transcription.done":
103+
return events
104+
105+
106+
@pytest.mark.asyncio
107+
@pytest.mark.parametrize("model_name", [MODEL_NAME])
108+
async def test_segment_timestamps(
109+
model_name, mary_had_lamb_audio_chunks, rocm_aiter_fa_attention
110+
):
111+
"""Segment timestamps are opt-in, aligned, and invisible when not asked for.
112+
113+
The alignment bound is the point of this test: the token index of a
114+
generated token leads the audio it describes by the streaming prefix
115+
minus the left pad minus the transcription delay. Subtracting only the
116+
delay - as https://github.com/vllm-project/vllm/issues/39735 proposes -
117+
leaves the 32-frame left pad in and puts every timestamp 2.56 s late,
118+
which the upper bound catches.
119+
"""
120+
server_args = ["--enforce-eager", "--max-model-len", "2048"]
121+
122+
if model_name.startswith("mistralai"):
123+
server_args += MISTRAL_FORMAT_ARGS
124+
125+
add_attention_backend(server_args, rocm_aiter_fa_attention)
126+
127+
chunk_duration_s = 1600 / 16000
128+
duration_s = len(mary_had_lamb_audio_chunks) * chunk_duration_s
129+
130+
with RemoteOpenAIServer(
131+
model_name, server_args, env_dict=REALTIME_ENV_OVERRIDES
132+
) as remote_server:
133+
ws_url = _get_websocket_url(remote_server)
134+
135+
# --- Not opted in: the wire must be byte-identical to before ------
136+
async with websockets.connect(ws_url) as ws:
137+
ack = await _start_session(ws, model_name)
138+
assert ack["timestamp_granularities"] == []
139+
140+
# (ROCm) generous timeout: first use triggers aiter JIT.
141+
events = await _stream_utterance(
142+
ws, mary_had_lamb_audio_chunks, timeout=600.0
143+
)
144+
done = events[-1]
145+
assert set(done) == {"type", "text", "usage"}
146+
assert all("segments" not in event for event in events)
147+
baseline_text = done["text"]
148+
149+
# --- Opted in: same text, plus timestamps -------------------------
150+
async with websockets.connect(ws_url) as ws:
151+
ack = await _start_session(ws, model_name, ["segment"])
152+
assert ack["timestamp_granularities"] == ["segment"]
153+
154+
events = await _stream_utterance(ws, mary_had_lamb_audio_chunks)
155+
done = events[-1]
156+
segments = done["segments"]
157+
158+
# Opting in must not change what was transcribed.
159+
assert done["text"] == baseline_text
160+
assert segments
161+
162+
# done repeats the deltas' segments, plus the trailing segment
163+
# that generation ended before any delta could carry.
164+
streamed = [
165+
segment
166+
for event in events
167+
if event["type"] == "transcription.delta"
168+
for segment in event["segments"]
169+
]
170+
assert segments[: len(streamed)] == streamed
171+
assert 0 <= len(segments) - len(streamed) <= 1
172+
173+
ends = [segment["end"] for segment in segments]
174+
assert ends == sorted(ends)
175+
assert all(end >= 0.08 for end in ends)
176+
assert all(abs(end / 0.08 - round(end / 0.08)) < 1e-6 for end in ends)
177+
178+
# Bounded on both sides: +32 frames of left pad would overshoot,
179+
# a negative offset would undershoot.
180+
assert 0.5 * duration_s <= ends[-1] <= duration_s + 0.5
181+
182+
# Entries are emission groups, so there are at most as many as
183+
# there are words, and together they reconstruct the transcript.
184+
assert len(segments) <= len(baseline_text.split())
185+
reconstructed = "".join(segment["text"] for segment in segments)
186+
assert baseline_text.endswith(reconstructed)
187+
assert len(reconstructed) >= 0.9 * len(baseline_text)
188+
189+
# --- The clock restarts on every utterance ------------------------
190+
async with websockets.connect(ws_url) as ws:
191+
await _start_session(ws, model_name, ["segment"])
192+
short_chunks = mary_had_lamb_audio_chunks[:40]
193+
194+
first = await _stream_utterance(ws, short_chunks)
195+
second = await _stream_utterance(ws, short_chunks)
196+
197+
assert first[-1]["segments"]
198+
assert second[-1]["segments"]
199+
# Not "greater than the first utterance's last end": each commit
200+
# is a new engine request with a fresh prompt and left pad.
201+
assert second[-1]["segments"][0]["end"] < 1.0
202+
203+
73204
@pytest.mark.asyncio
74205
@pytest.mark.parametrize("model_name", [MODEL_NAME])
75206
async def test_multi_chunk_streaming(
@@ -95,17 +226,8 @@ async def test_multi_chunk_streaming(
95226
await send_event(ws, {"type": "session.update", "model": model_name})
96227

97228
# Wait for the server to acknowledge the session update.
98-
try:
99-
while True:
100-
event = await receive_event(ws, timeout=5.0)
101-
if event["type"] == "session.updated":
102-
break
103-
except TimeoutError:
104-
warnings.warn(
105-
f"session.updated not received within {5.0}s after "
106-
"session.update. The server may not implement this event.",
107-
stacklevel=2,
108-
)
229+
event = await receive_event(ws, timeout=10.0)
230+
assert event["type"] == "session.updated"
109231

110232
# (ROCm) Warm-up: send a non-final commit (required to start
111233
# transcription) with a small audio chunk to trigger aiter
@@ -203,17 +325,8 @@ async def test_empty_commit_does_not_crash_engine(
203325

204326
await send_event(ws, {"type": "session.update", "model": model_name})
205327

206-
try:
207-
while True:
208-
event = await receive_event(ws, timeout=5.0)
209-
if event["type"] == "session.updated":
210-
break
211-
except TimeoutError:
212-
warnings.warn(
213-
f"session.updated not received within {5.0}s after "
214-
"session.update. The server may not implement this event.",
215-
stacklevel=2,
216-
)
328+
event = await receive_event(ws, timeout=10.0)
329+
assert event["type"] == "session.updated"
217330

218331
# Start generation without sending any audio
219332
await send_event(ws, {"type": "input_audio_buffer.commit"})
@@ -239,17 +352,8 @@ async def test_empty_commit_does_not_crash_engine(
239352

240353
await send_event(ws, {"type": "session.update", "model": model_name})
241354

242-
try:
243-
while True:
244-
event = await receive_event(ws, timeout=5.0)
245-
if event["type"] == "session.updated":
246-
break
247-
except TimeoutError:
248-
warnings.warn(
249-
f"session.updated not received within {5.0}s after "
250-
"session.update. The server may not implement this event.",
251-
stacklevel=2,
252-
)
355+
event = await receive_event(ws, timeout=10.0)
356+
assert event["type"] == "session.updated"
253357

254358
# Start transcription
255359
await send_event(ws, {"type": "input_audio_buffer.commit"})

0 commit comments

Comments
 (0)