Skip to content

Commit c3e5841

Browse files
CopilotbaijumeswaniPrathik Rao
authored
Replace callback-based audio streaming with iterator pattern (#597)
`AudioClient.transcribe_streaming` required a callback argument, inconsistent with `ChatClient.complete_streaming_chat` which returns a `Generator`. Align the audio client to the same iterator pattern used across the SDK. ### Changes - **`audio_client.py`**: Replace `transcribe_streaming(path, callback) -> None` with `transcribe_streaming(path) -> Generator[AudioTranscriptionResponse]` using the same `threading.Thread` + `queue.Queue` + sentinel pattern from `ChatClient._stream_chunks` - **`test_audio_client.py`**: Update streaming tests to `for chunk in` consumption; remove `test_should_raise_for_streaming_invalid_callback` (no longer applicable) - **`test/README.md`**: Update test counts (7→6 audio, 32→31 total) ### Usage ```python # Before def on_chunk(chunk): print(chunk.text) audio_client.transcribe_streaming("recording.mp3", on_chunk) # After for chunk in audio_client.transcribe_streaming("recording.mp3"): print(chunk.text) ``` Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: baijumeswani <12852605+baijumeswani@users.noreply.github.com> Co-authored-by: Prathik Rao <prathikrao@microsoft.com>
1 parent 7ad2ef0 commit c3e5841

File tree

3 files changed

+51
-43
lines changed

3 files changed

+51
-43
lines changed

sdk/python/src/openai/audio_client.py

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
import json
99
import logging
10+
import queue
11+
import threading
1012
from dataclasses import dataclass
11-
from typing import Callable, Optional
13+
from typing import Generator, List, Optional
1214

1315
from ..detail.core_interop import CoreInterop, InteropRequest
1416
from ..exception import FoundryLocalException
@@ -114,40 +116,62 @@ def transcribe(self, audio_file_path: str) -> AudioTranscriptionResponse:
114116
data = json.loads(response.data)
115117
return AudioTranscriptionResponse(text=data.get("text", ""))
116118

119+
def _stream_chunks(self, request_json: str) -> Generator[AudioTranscriptionResponse, None, None]:
120+
"""Background-thread generator that yields parsed chunks from the native streaming call."""
121+
_SENTINEL = object()
122+
chunk_queue: queue.Queue = queue.Queue()
123+
errors: List[Exception] = []
124+
125+
def _on_chunk(chunk_str: str) -> None:
126+
chunk_data = json.loads(chunk_str)
127+
chunk_queue.put(AudioTranscriptionResponse(text=chunk_data.get("text", "")))
128+
129+
def _run() -> None:
130+
try:
131+
resp = self._core_interop.execute_command_with_callback(
132+
"audio_transcribe",
133+
InteropRequest(params={"OpenAICreateRequest": request_json}),
134+
_on_chunk,
135+
)
136+
if resp.error is not None:
137+
errors.append(
138+
FoundryLocalException(
139+
f"Streaming audio transcription failed for model '{self.model_id}': {resp.error}"
140+
)
141+
)
142+
except Exception as exc:
143+
errors.append(exc)
144+
finally:
145+
chunk_queue.put(_SENTINEL)
146+
147+
threading.Thread(target=_run, daemon=True).start()
148+
while (item := chunk_queue.get()) is not _SENTINEL:
149+
yield item
150+
if errors:
151+
raise errors[0]
152+
117153
def transcribe_streaming(
118154
self,
119155
audio_file_path: str,
120-
callback: Callable[[AudioTranscriptionResponse], None],
121-
) -> None:
156+
) -> Generator[AudioTranscriptionResponse, None, None]:
122157
"""Transcribe an audio file with streaming chunks.
123158
124-
Each chunk is passed to *callback* as an ``AudioTranscriptionResponse``.
159+
Consume with a standard ``for`` loop::
160+
161+
for chunk in audio_client.transcribe_streaming("recording.mp3"):
162+
print(chunk.text, end="", flush=True)
125163
126164
Args:
127165
audio_file_path: Path to the audio file to transcribe.
128-
callback: Called with each incremental transcription chunk.
166+
167+
Returns:
168+
A generator of ``AudioTranscriptionResponse`` objects.
129169
130170
Raises:
131171
ValueError: If *audio_file_path* is not a non-empty string.
132172
FoundryLocalException: If the underlying native transcription command fails.
133173
"""
134174
self._validate_audio_file_path(audio_file_path)
135175

136-
if not callable(callback):
137-
raise TypeError("Callback must be a valid function.")
138-
139176
request_json = self._create_request_json(audio_file_path)
140-
request = InteropRequest(params={"OpenAICreateRequest": request_json})
141-
142-
def callback_handler(chunk_str: str):
143-
chunk_data = json.loads(chunk_str)
144-
chunk = AudioTranscriptionResponse(text=chunk_data.get("text", ""))
145-
callback(chunk)
146-
147-
response = self._core_interop.execute_command_with_callback(
148-
"audio_transcribe", request, callback_handler
149-
)
150-
if response.error is not None:
151-
raise FoundryLocalException(
152-
f"Streaming audio transcription failed for model '{self.model_id}': {response.error}"
153-
)
177+
return self._stream_chunks(request_json)

sdk/python/test/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ test/
5050
│ └── test_model_load_manager.py # ModelLoadManager core interop & web service (5 tests)
5151
└── openai/
5252
├── test_chat_client.py # Chat completions, streaming, error validation (7 tests)
53-
└── test_audio_client.py # Audio transcription (7 tests)
53+
└── test_audio_client.py # Audio transcription (6 tests)
5454
```
5555

56-
**Total: 32 tests**
56+
**Total: 31 tests**
5757

5858
## Key conventions
5959

sdk/python/test/openai/test_audio_client.py

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,13 @@ def test_should_transcribe_audio_streaming(self, catalog):
8888
audio_client.settings.temperature = 0.0
8989

9090
chunks = []
91-
92-
def on_chunk(chunk):
91+
for chunk in audio_client.transcribe_streaming(AUDIO_FILE_PATH):
9392
assert chunk is not None
9493
assert hasattr(chunk, "text")
9594
assert isinstance(chunk.text, str)
9695
assert len(chunk.text) > 0
9796
chunks.append(chunk.text)
9897

99-
audio_client.transcribe_streaming(AUDIO_FILE_PATH, on_chunk)
100-
10198
full_text = "".join(chunks)
10299
assert full_text == EXPECTED_TEXT
103100
finally:
@@ -114,14 +111,11 @@ def test_should_transcribe_audio_streaming_with_temperature(self, catalog):
114111
audio_client.settings.temperature = 0.0
115112

116113
chunks = []
117-
118-
def on_chunk(chunk):
114+
for chunk in audio_client.transcribe_streaming(AUDIO_FILE_PATH):
119115
assert chunk is not None
120116
assert isinstance(chunk.text, str)
121117
chunks.append(chunk.text)
122118

123-
audio_client.transcribe_streaming(AUDIO_FILE_PATH, on_chunk)
124-
125119
full_text = "".join(chunks)
126120
assert full_text == EXPECTED_TEXT
127121
finally:
@@ -143,14 +137,4 @@ def test_should_raise_for_streaming_empty_audio_file_path(self, catalog):
143137
audio_client = model.get_audio_client()
144138

145139
with pytest.raises(ValueError, match="Audio file path must be a non-empty string"):
146-
audio_client.transcribe_streaming("", lambda chunk: None)
147-
148-
def test_should_raise_for_streaming_invalid_callback(self, catalog):
149-
"""transcribe_streaming with invalid callback should raise."""
150-
model = catalog.get_model(AUDIO_MODEL_ALIAS)
151-
assert model is not None
152-
audio_client = model.get_audio_client()
153-
154-
for invalid_callback in [None, 42, {}, "not a function"]:
155-
with pytest.raises(TypeError, match="Callback must be a valid function"):
156-
audio_client.transcribe_streaming(AUDIO_FILE_PATH, invalid_callback)
140+
audio_client.transcribe_streaming("")

0 commit comments

Comments
 (0)