1use crate::audio::AudioInput;
11use crate::error::{ProviderError, Result};
12use crate::providers::{Segment, TranscriptionOptions, TranscriptionResult};
13use crate::remote::limits::{validate_segments, validate_text_bounds, TranscriptLimits};
14use std::future::Future;
15use std::path::PathBuf;
16use std::sync::Arc;
17
18pub const DEFAULT_REMOTE_STT_CHUNK_SECS: f64 = 210.0;
20
21pub fn needs_time_chunk(duration_secs: f64, chunk_secs: f64) -> bool {
23 duration_secs.is_finite()
24 && chunk_secs.is_finite()
25 && chunk_secs > 0.0
26 && duration_secs > chunk_secs
27}
28
29#[derive(Debug, Clone, Copy, PartialEq)]
31pub struct ChunkWindow {
32 pub start_sample: usize,
33 pub end_sample: usize,
34 pub offset_secs: f64,
35}
36
37pub fn plan_chunk_windows(
39 total_samples: usize,
40 sample_rate: u32,
41 chunk_secs: f64,
42) -> Vec<ChunkWindow> {
43 if total_samples == 0 || sample_rate == 0 || !chunk_secs.is_finite() || chunk_secs <= 0.0 {
44 return vec![ChunkWindow {
45 start_sample: 0,
46 end_sample: total_samples,
47 offset_secs: 0.0,
48 }];
49 }
50 let chunk_samples = ((chunk_secs * f64::from(sample_rate)).round() as usize).max(1);
51 if total_samples <= chunk_samples {
52 return vec![ChunkWindow {
53 start_sample: 0,
54 end_sample: total_samples,
55 offset_secs: 0.0,
56 }];
57 }
58 let mut out = Vec::new();
59 let mut start = 0usize;
60 while start < total_samples {
61 let end = (start + chunk_samples).min(total_samples);
62 out.push(ChunkWindow {
63 start_sample: start,
64 end_sample: end,
65 offset_secs: start as f64 / f64::from(sample_rate),
66 });
67 if end == total_samples {
68 break;
69 }
70 start = end;
71 }
72 out
73}
74
75pub fn slice_audio_window(input: &AudioInput, window: ChunkWindow) -> Result<AudioInput> {
77 let samples = input.samples();
78 let start = window.start_sample.min(samples.len());
79 let end = window.end_sample.min(samples.len()).max(start);
80 if start == end {
81 return Err(ProviderError::Other {
82 message: "remote STT chunk window is empty".into(),
83 }
84 .into());
85 }
86 let slice: Arc<[f32]> = Arc::from(samples[start..end].to_vec());
87 let sr = input.sample_rate();
88 let duration = if sr > 0 {
89 (end - start) as f64 / f64::from(sr)
90 } else {
91 0.0
92 };
93 Ok(AudioInput::from_parts_unchecked(
94 PathBuf::from(format!(
95 "pcm://remote-stt-chunk/{:.3}-{:.3}",
96 window.offset_secs,
97 window.offset_secs + duration
98 )),
99 slice,
100 sr,
101 duration,
102 ))
103}
104
105pub fn soft_split_text_segments(
111 text: &str,
112 start: f64,
113 end: f64,
114 max_chars: usize,
115) -> Vec<Segment> {
116 let max_chars = max_chars.max(1);
117 let chars: Vec<char> = text.chars().collect();
118 if chars.is_empty() {
119 return vec![Segment::from_parts_unchecked(start, end, String::new())];
120 }
121 if chars.len() <= max_chars {
122 return vec![Segment::from_parts_unchecked(start, end, text.to_string())];
123 }
124 let n_parts = chars.len().div_ceil(max_chars);
125 let span = (end - start).max(0.0);
126 let mut segs = Vec::with_capacity(n_parts);
127 for i in 0..n_parts {
128 let c0 = i * max_chars;
129 let c1 = ((i + 1) * max_chars).min(chars.len());
130 let piece: String = chars[c0..c1].iter().collect();
131 let t0 = start + span * (c0 as f64 / chars.len() as f64);
132 let t1 = start + span * (c1 as f64 / chars.len() as f64);
133 segs.push(Segment::from_parts_unchecked(t0, t1.max(t0), piece));
134 }
135 segs
136}
137
138pub fn normalize_chunk_segments(
140 segments: &[Segment],
141 offset_secs: f64,
142 limits: TranscriptLimits,
143) -> Vec<Segment> {
144 let mut out = Vec::new();
145 for seg in segments {
146 let start = seg.start() + offset_secs;
147 let end = seg.end() + offset_secs;
148 let text = seg.text();
149 if text.chars().count() > limits.max_segment_chars {
150 out.extend(soft_split_text_segments(
151 text,
152 start,
153 end,
154 limits.max_segment_chars,
155 ));
156 } else {
157 out.push(Segment::from_parts_unchecked(start, end, text.to_string()));
158 }
159 }
160 out
161}
162
163pub fn stitch_chunk_results(
165 parts: &[(f64, TranscriptionResult)],
166 full_duration_secs: f64,
167 provider: &str,
168 limits: TranscriptLimits,
169) -> Result<TranscriptionResult> {
170 if parts.is_empty() {
171 return Err(ProviderError::TranscriptionFailed {
172 reason: "remote STT chunk-and-stitch produced no chunks".into(),
173 }
174 .into());
175 }
176
177 let mut texts: Vec<String> = Vec::with_capacity(parts.len());
178 let mut segments: Vec<Segment> = Vec::new();
179 let mut timestamps_reliable = true;
180 let mut backend_kind = parts[0].1.backend_kind();
181 let model = parts[0].1.model().to_string();
182 let language = parts[0].1.language().map(|s| s.to_string());
183 let provider_name = parts[0].1.provider().to_string();
184
185 for (offset, r) in parts {
186 let t = r.text().trim();
187 if !t.is_empty() {
188 texts.push(t.to_string());
189 }
190 timestamps_reliable &= r.timestamps_reliable();
191 if matches!(r.backend_kind(), crate::providers::BackendKind::Asr) {
193 backend_kind = crate::providers::BackendKind::Asr;
194 }
195 segments.extend(normalize_chunk_segments(r.segments(), *offset, limits));
196 }
197
198 let text = join_transcript_parts(&texts);
199 validate_text_bounds(&text, None, limits, provider)?;
200 validate_segments(&segments, full_duration_secs, limits, provider)?;
201
202 let mut result = TranscriptionResult::openrouter(
203 text,
204 segments,
205 language,
206 model,
207 full_duration_secs,
208 timestamps_reliable,
209 );
210 result.set_provider(if provider_name.is_empty() {
211 provider
212 } else {
213 provider_name.as_str()
214 });
215 result.set_backend_kind(backend_kind);
216 result.set_timestamps_reliable(timestamps_reliable);
217 result.validate_segments()?;
218 Ok(result)
219}
220
221fn join_transcript_parts(parts: &[String]) -> String {
222 let mut out = String::new();
223 for p in parts {
224 let p = p.trim();
225 if p.is_empty() {
226 continue;
227 }
228 if !out.is_empty() && !out.ends_with(|c: char| c.is_whitespace()) {
229 out.push(' ');
230 }
231 out.push_str(p);
232 }
233 out
234}
235
236pub async fn transcribe_maybe_chunked<F, Fut>(
240 input: &AudioInput,
241 options: &TranscriptionOptions,
242 provider: &str,
243 chunk_secs: f64,
244 mut one_shot: F,
245) -> Result<TranscriptionResult>
246where
247 F: FnMut(AudioInput, TranscriptionOptions) -> Fut,
248 Fut: Future<Output = Result<TranscriptionResult>>,
249{
250 let duration = input.duration_secs();
251 if !needs_time_chunk(duration, chunk_secs) {
252 return one_shot(input.clone(), options.clone()).await;
253 }
254
255 let windows = plan_chunk_windows(input.len(), input.sample_rate(), chunk_secs);
256 tracing::info!(
257 provider,
258 duration_secs = duration,
259 chunk_secs,
260 chunks = windows.len(),
261 "remote STT time-chunk-and-stitch (JOE-2212)"
262 );
263
264 let mut parts: Vec<(f64, TranscriptionResult)> = Vec::with_capacity(windows.len());
265 for (i, window) in windows.iter().enumerate() {
266 if let Some(flag) = options.cancel.as_ref() {
267 if flag.is_cancelled() {
268 return Err(ProviderError::Cancelled.into());
269 }
270 }
271 let chunk_input = slice_audio_window(input, *window)?;
272 tracing::debug!(
273 provider,
274 chunk = i + 1,
275 of = windows.len(),
276 offset_secs = window.offset_secs,
277 chunk_duration = chunk_input.duration_secs(),
278 "remote STT chunk"
279 );
280 let result = one_shot(chunk_input, options.clone()).await.map_err(|e| {
281 match e {
283 crate::error::TranscriptionError::Provider(
284 ProviderError::TranscriptionFailed { reason },
285 ) => ProviderError::TranscriptionFailed {
286 reason: format!(
287 "chunk {}/{} (offset {:.1}s): {reason}",
288 i + 1,
289 windows.len(),
290 window.offset_secs
291 ),
292 }
293 .into(),
294 other => other,
295 }
296 })?;
297 parts.push((window.offset_secs, result));
298 }
299
300 stitch_chunk_results(&parts, duration, provider, TranscriptLimits::default())
301}
302
303pub fn effective_chunk_secs() -> f64 {
305 match std::env::var("AURUM_REMOTE_STT_CHUNK_SECS") {
306 Ok(s) => {
307 let v: f64 = s.trim().parse().unwrap_or(DEFAULT_REMOTE_STT_CHUNK_SECS);
308 if v.is_finite() && v > 0.0 {
309 v
310 } else {
311 DEFAULT_REMOTE_STT_CHUNK_SECS
312 }
313 }
314 Err(_) => DEFAULT_REMOTE_STT_CHUNK_SECS,
315 }
316}
317
318#[cfg(test)]
320pub fn silence_input(duration_secs: f64) -> AudioInput {
321 use crate::audio::WHISPER_SAMPLE_RATE;
322 let n = (duration_secs * f64::from(WHISPER_SAMPLE_RATE)).round() as usize;
323 AudioInput::from_pcm_slice(&vec![0.0f32; n.max(1)], WHISPER_SAMPLE_RATE).unwrap()
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::audio::WHISPER_SAMPLE_RATE;
330 use crate::providers::BackendKind;
331
332 #[test]
333 fn needs_chunk_threshold() {
334 assert!(!needs_time_chunk(100.0, 210.0));
335 assert!(!needs_time_chunk(210.0, 210.0));
336 assert!(needs_time_chunk(210.1, 210.0));
337 assert!(!needs_time_chunk(f64::NAN, 210.0));
338 }
339
340 #[test]
341 fn plans_four_windows_for_685s() {
342 let total = (685.0 * f64::from(WHISPER_SAMPLE_RATE)) as usize;
343 let windows = plan_chunk_windows(total, WHISPER_SAMPLE_RATE, 210.0);
344 assert_eq!(windows.len(), 4);
345 assert_eq!(windows[0].offset_secs, 0.0);
346 assert!((windows[1].offset_secs - 210.0).abs() < 0.01);
347 assert_eq!(windows.last().unwrap().end_sample, total);
348 for w in windows.windows(2) {
350 assert_eq!(w[0].end_sample, w[1].start_sample);
351 }
352 }
353
354 #[test]
355 fn single_window_when_short() {
356 let total = WHISPER_SAMPLE_RATE as usize * 30;
357 let windows = plan_chunk_windows(total, WHISPER_SAMPLE_RATE, 210.0);
358 assert_eq!(windows.len(), 1);
359 assert_eq!(windows[0].end_sample, total);
360 }
361
362 #[test]
363 fn soft_split_respects_max_chars() {
364 let text = "a".repeat(20);
365 let segs = soft_split_text_segments(&text, 0.0, 10.0, 8);
366 assert!(segs.len() > 1);
367 assert!(segs.iter().all(|s| s.text().chars().count() <= 8));
368 let joined: String = segs.iter().map(|s| s.text()).collect();
369 assert_eq!(joined, text);
370 }
371
372 #[test]
373 fn stitch_offsets_segments() {
374 let mut a = TranscriptionResult::openrouter(
375 "hello".to_string(),
376 vec![Segment::from_parts_unchecked(0.0, 1.0, "hello".to_string())],
377 None,
378 "m".to_string(),
379 1.0,
380 true,
381 );
382 a.set_provider("openai");
383 a.set_backend_kind(BackendKind::Asr);
384 a.set_timestamps_reliable(true);
385
386 let mut b = TranscriptionResult::openrouter(
387 "world".to_string(),
388 vec![Segment::from_parts_unchecked(0.0, 1.0, "world".to_string())],
389 None,
390 "m".to_string(),
391 1.0,
392 true,
393 );
394 b.set_provider("openai");
395 b.set_backend_kind(BackendKind::Asr);
396 b.set_timestamps_reliable(true);
397
398 let stitched = stitch_chunk_results(
399 &[(0.0, a), (210.0, b)],
400 420.0,
401 "openai",
402 TranscriptLimits::default(),
403 )
404 .unwrap();
405 assert_eq!(stitched.text(), "hello world");
406 assert_eq!(stitched.segments().len(), 2);
407 assert!((stitched.segments()[1].start() - 210.0).abs() < 1e-9);
408 assert!(stitched.timestamps_reliable());
409 assert_eq!(stitched.provider(), "openai");
410 }
411
412 #[tokio::test]
413 async fn maybe_chunked_short_is_single_call() {
414 let input = silence_input(5.0);
415 let opts = TranscriptionOptions {
416 model: "whisper-1".into(),
417 language: "en".into(),
418 timestamps: false,
419 cancel: None,
420 };
421 let mut calls = 0u32;
422 let out = transcribe_maybe_chunked(&input, &opts, "t", 210.0, |inp, _| {
423 calls += 1;
424 let mut r = TranscriptionResult::openrouter(
425 "ok".to_string(),
426 vec![Segment::from_parts_unchecked(
427 0.0,
428 inp.duration_secs(),
429 "ok".to_string(),
430 )],
431 None,
432 "whisper-1".to_string(),
433 inp.duration_secs(),
434 false,
435 );
436 r.set_provider("t");
437 async move { Ok(r) }
438 })
439 .await
440 .unwrap();
441 assert_eq!(calls, 1);
442 assert_eq!(out.text(), "ok");
443 }
444
445 #[tokio::test]
446 async fn maybe_chunked_long_invokes_multiple() {
447 let input = silence_input(500.0);
448 let opts = TranscriptionOptions {
449 model: "whisper-1".into(),
450 language: "en".into(),
451 timestamps: false,
452 cancel: None,
453 };
454 let mut calls = 0u32;
455 let out = transcribe_maybe_chunked(&input, &opts, "t", 210.0, |inp, _| {
456 calls += 1;
457 let label = format!("c{calls}");
458 let mut r = TranscriptionResult::openrouter(
459 label.clone(),
460 vec![Segment::from_parts_unchecked(
461 0.0,
462 inp.duration_secs(),
463 label,
464 )],
465 None,
466 "whisper-1".into(),
467 inp.duration_secs(),
468 false,
469 );
470 r.set_provider("t");
471 async move { Ok(r) }
472 })
473 .await
474 .unwrap();
475 assert_eq!(calls, 3); assert!(out.text().contains(' '));
477 assert_eq!(out.segments().len(), 3);
478 assert!((out.duration_secs() - 500.0).abs() < 0.02);
479 }
480
481 #[tokio::test]
482 async fn maybe_chunked_honours_cancel() {
483 let input = silence_input(500.0);
484 let flag = crate::cancel::CancelFlag::new();
485 flag.cancel();
486 let opts = TranscriptionOptions {
487 model: "whisper-1".into(),
488 language: "en".into(),
489 timestamps: false,
490 cancel: Some(flag),
491 };
492 let err = transcribe_maybe_chunked(&input, &opts, "t", 210.0, |_inp, _| async {
493 unreachable!("should cancel before first shot")
494 })
495 .await
496 .unwrap_err();
497 assert!(err.to_string().to_lowercase().contains("cancel"));
498 }
499}