foundry_local_sdk/openai/
live_audio_session.rs1#![allow(deprecated)] use std::os::raw::c_int;
34use std::panic::{catch_unwind, AssertUnwindSafe};
35use std::pin::Pin;
36use std::sync::Arc;
37use std::task::{Context, Poll};
38
39use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
40use tokio_util::sync::CancellationToken;
41
42use crate::detail::api::Api;
43use crate::detail::ffi::{flItem, flStreamingCallbackData, FOUNDRY_LOCAL_ITEM_BYTES};
44use crate::detail::items::{
45 make_audio_item, read_speech_segment, read_text_item, SpeechSegmentText,
46};
47use crate::detail::native::NativeModel;
48use crate::detail::session::{NativeItemQueue, NativeRequest, NativeSession};
49use crate::detail::task::spawn_blocking;
50use crate::error::{FoundryLocalError, Result};
51
52#[derive(Debug, Clone)]
59pub struct LiveAudioTranscriptionOptions {
60 pub sample_rate: u32,
62 pub channels: u32,
64 pub language: Option<String>,
66}
67
68impl Default for LiveAudioTranscriptionOptions {
69 fn default() -> Self {
70 Self {
71 sample_rate: 16000,
72 channels: 1,
73 language: None,
74 }
75 }
76}
77
78#[derive(Debug, Clone, serde::Deserialize)]
80struct LiveAudioTranscriptionRaw {
81 #[serde(default)]
82 is_final: bool,
83 #[serde(default)]
84 text: String,
85 start_time: Option<f64>,
86 end_time: Option<f64>,
87 id: Option<String>,
88}
89
90#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
96pub struct ContentPart {
97 pub text: String,
99 pub transcript: String,
101}
102
103#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
109pub struct LiveAudioTranscriptionResponse {
110 pub content: Vec<ContentPart>,
113 pub is_final: bool,
117 pub start_time: Option<f64>,
119 pub end_time: Option<f64>,
121 pub id: Option<String>,
123}
124
125impl LiveAudioTranscriptionResponse {
126 pub fn from_json(json: &str) -> Result<Self> {
128 serde_json::from_str::<LiveAudioTranscriptionRaw>(json)
129 .map(Self::from_raw)
130 .map_err(FoundryLocalError::from)
131 }
132
133 fn from_raw(raw: LiveAudioTranscriptionRaw) -> Self {
134 Self {
135 content: vec![ContentPart {
136 transcript: raw.text.clone(),
137 text: raw.text,
138 }],
139 is_final: raw.is_final,
140 start_time: raw.start_time,
141 end_time: raw.end_time,
142 id: raw.id,
143 }
144 }
145
146 fn from_text(text: String, is_final: bool) -> Self {
148 Self {
149 content: vec![ContentPart {
150 transcript: text.clone(),
151 text,
152 }],
153 is_final,
154 start_time: None,
155 end_time: None,
156 id: None,
157 }
158 }
159
160 fn from_segment(seg: SpeechSegmentText) -> Self {
162 Self {
163 content: vec![ContentPart {
164 transcript: seg.text.clone(),
165 text: seg.text,
166 }],
167 is_final: seg.is_final,
168 start_time: seg.start_time_s,
169 end_time: seg.end_time_s,
170 id: None,
171 }
172 }
173}
174
175#[derive(Debug, Clone, serde::Deserialize)]
177pub struct CoreErrorResponse {
178 pub code: String,
180 pub message: String,
182 #[serde(rename = "isTransient", default)]
184 pub is_transient: bool,
185}
186
187impl CoreErrorResponse {
188 pub fn try_parse(error_string: &str) -> Option<Self> {
191 serde_json::from_str(error_string).ok()
192 }
193}
194
195pub struct LiveAudioTranscriptionStream {
202 rx: UnboundedReceiver<Result<LiveAudioTranscriptionResponse>>,
203}
204
205impl futures_core::Stream for LiveAudioTranscriptionStream {
206 type Item = Result<LiveAudioTranscriptionResponse>;
207
208 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
209 self.rx.poll_recv(cx)
210 }
211}
212
213#[derive(Default)]
216struct SessionState {
217 started: bool,
218 stopped: bool,
219 queue: Option<Arc<NativeItemQueue>>,
220 output_rx: Option<UnboundedReceiver<Result<LiveAudioTranscriptionResponse>>>,
221 worker: Option<tokio::task::JoinHandle<()>>,
222}
223
224#[deprecated(
239 since = "2.0.0",
240 note = "The OpenAI direct clients are deprecated; use the Session API instead \
241 (`AudioSession::new(&model)` with streaming)."
242)]
243pub struct LiveAudioTranscriptionSession {
244 model: NativeModel,
245 pub settings: LiveAudioTranscriptionOptions,
248 state: tokio::sync::Mutex<SessionState>,
249}
250
251impl LiveAudioTranscriptionSession {
252 pub(crate) fn new(_model_id: &str, model: NativeModel) -> Self {
253 Self {
254 model,
255 settings: LiveAudioTranscriptionOptions::default(),
256 state: tokio::sync::Mutex::new(SessionState::default()),
257 }
258 }
259
260 pub async fn start(&self, ct: Option<CancellationToken>) -> Result<()> {
265 let mut state = self.state.lock().await;
266
267 if state.started {
268 return Err(FoundryLocalError::Validation {
269 reason: "Streaming session already started. Call stop() first.".into(),
270 });
271 }
272
273 if let Some(token) = &ct {
274 if token.is_cancelled() {
275 return Err(FoundryLocalError::CommandExecution {
276 reason: "Start cancelled".into(),
277 });
278 }
279 }
280
281 let settings = self.settings.clone();
282 let model = self.model.clone();
283 let api = Arc::clone(&model.api);
284
285 let queue = Arc::new(NativeItemQueue::new(api)?);
287
288 let (output_tx, output_rx) =
289 tokio::sync::mpsc::unbounded_channel::<Result<LiveAudioTranscriptionResponse>>();
290
291 let worker_queue = Arc::clone(&queue);
292 let worker = tokio::task::spawn_blocking(move || {
293 run_worker(model, settings, worker_queue, output_tx);
294 });
295
296 state.started = true;
297 state.stopped = false;
298 state.queue = Some(queue);
299 state.output_rx = Some(output_rx);
300 state.worker = Some(worker);
301
302 Ok(())
303 }
304
305 pub async fn append(&self, pcm_data: &[u8], ct: Option<CancellationToken>) -> Result<()> {
309 if let Some(token) = &ct {
310 if token.is_cancelled() {
311 return Err(FoundryLocalError::CommandExecution {
312 reason: "Append cancelled".into(),
313 });
314 }
315 }
316
317 let queue = {
318 let state = self.state.lock().await;
319 if !state.started || state.stopped {
320 return Err(FoundryLocalError::Validation {
321 reason: "No active streaming session. Call start() first.".into(),
322 });
323 }
324 state
325 .queue
326 .clone()
327 .ok_or_else(|| FoundryLocalError::Internal {
328 reason: "Input queue not available — session may be in an invalid state".into(),
329 })?
330 };
331
332 let data = pcm_data.to_vec();
333 spawn_blocking(move || queue.push_bytes(&data, FOUNDRY_LOCAL_ITEM_BYTES)).await
334 }
335
336 pub async fn get_stream(&self) -> Result<LiveAudioTranscriptionStream> {
341 let mut state = self.state.lock().await;
342 let rx = state
343 .output_rx
344 .take()
345 .ok_or_else(|| FoundryLocalError::Validation {
346 reason: "No active streaming session, or stream already taken. \
347 Call start() first and only call get_stream() once."
348 .into(),
349 })?;
350 Ok(LiveAudioTranscriptionStream { rx })
351 }
352
353 pub async fn stop(&self, _ct: Option<CancellationToken>) -> Result<()> {
360 let worker = {
361 let mut state = self.state.lock().await;
362 if !state.started || state.stopped {
363 return Ok(());
364 }
365 state.stopped = true;
366 if let Some(queue) = &state.queue {
367 queue.mark_finished();
368 }
369 state.worker.take()
370 };
371
372 if let Some(handle) = worker {
373 let _ = handle.await;
374 }
375 Ok(())
376 }
377}
378
379impl Drop for LiveAudioTranscriptionSession {
380 fn drop(&mut self) {
386 let state = self.state.get_mut();
387 if state.started && !state.stopped {
388 state.stopped = true;
389 if let Some(queue) = &state.queue {
390 queue.mark_finished();
391 }
392 state.worker.take();
396 }
397 }
398}
399
400struct LiveCtx {
402 api: Arc<Api>,
403 tx: UnboundedSender<Result<LiveAudioTranscriptionResponse>>,
404}
405
406unsafe extern "C" fn live_trampoline(
407 data: flStreamingCallbackData,
408 user_data: *mut std::ffi::c_void,
409) -> c_int {
410 if user_data.is_null() {
411 return 0;
412 }
413 let result = catch_unwind(AssertUnwindSafe(|| {
414 let ctx = &*(user_data as *const LiveCtx);
415 let queue = data.item_queue;
416 if queue.is_null() {
417 return 0;
418 }
419 let item_api = ctx.api.item_api();
420 loop {
421 let mut item: *mut flItem = std::ptr::null_mut();
422 if !(item_api.ItemQueue_TryPop)(queue, &mut item) {
423 break;
424 }
425 if item.is_null() {
426 continue;
427 }
428 let response = (|| -> Result<Option<LiveAudioTranscriptionResponse>> {
433 if let Some(text) = read_text_item(&ctx.api, item)? {
434 return Ok((!text.is_empty())
435 .then(|| LiveAudioTranscriptionResponse::from_text(text, false)));
436 }
437
438 Ok(read_speech_segment(&ctx.api, item)?
439 .filter(|seg| !seg.text.is_empty())
440 .map(LiveAudioTranscriptionResponse::from_segment))
441 })();
442
443 (item_api.Item_Release)(item);
444
445 let response = match response {
446 Ok(response) => response,
447 Err(error) => {
448 let _ = ctx.tx.send(Err(error));
449 return 1; }
451 };
452
453 if let Some(response) = response {
454 if ctx.tx.send(Ok(response)).is_err() {
455 return 1; }
457 }
458 }
459 0
460 }));
461 result.unwrap_or(1)
462}
463
464fn run_worker(
467 model: NativeModel,
468 settings: LiveAudioTranscriptionOptions,
469 queue: Arc<NativeItemQueue>,
470 output_tx: UnboundedSender<Result<LiveAudioTranscriptionResponse>>,
471) {
472 let api = Arc::clone(&model.api);
473
474 let run = (|| -> Result<()> {
475 let session = NativeSession::create(&model)?;
476
477 let guard = session.lock_ops();
482
483 let mut ctx = Box::new(LiveCtx {
484 api: Arc::clone(&api),
485 tx: output_tx.clone(),
486 });
487 let ctx_ptr = &mut *ctx as *mut LiveCtx as *mut std::ffi::c_void;
488 session.set_streaming_callback(Some(live_trampoline), ctx_ptr)?;
489
490 let request = NativeRequest::new(Arc::clone(&api))?;
491 let format = make_audio_item(
492 &api,
493 &[],
494 Some("pcm"),
495 settings.sample_rate as i32,
496 settings.channels as i32,
497 )?;
498 request.add_item(format, true)?;
499 request.add_item(queue.as_item_ptr(), false)?;
501
502 let response = session.process_request(&request);
503
504 let _ = session.set_streaming_callback(None, std::ptr::null_mut());
508 drop(guard);
509 let response = response?;
510
511 let mut final_text = String::new();
515 for i in 0..response.item_count() {
516 let text = match response.item_text(i)? {
517 Some(text) => Some(text),
518 None => response.item_speech_result_text(i)?,
519 };
520
521 if let Some(text) = text {
522 final_text.push_str(&text);
523 }
524 }
525
526 drop(ctx);
527
528 if !final_text.is_empty() {
529 let _ = output_tx.send(Ok(LiveAudioTranscriptionResponse::from_text(
530 final_text, true,
531 )));
532 }
533 Ok(())
534 })();
535
536 if let Err(e) = run {
537 let _ = output_tx.send(Err(e));
538 }
539 drop(queue);
541}