1#![deny(missing_docs)]
8#![forbid(unsafe_code)]
9
10use std::{
11 future::Future,
12 io::Cursor,
13 pin::Pin,
14 sync::{Arc, PoisonError, RwLock},
15 time::Duration,
16};
17
18use anyhow::{Context, ensure};
19use futures::{StreamExt, stream};
20use hound::{SampleFormat, WavReader};
21use ruopus::encode_ogg_opus;
22use serde::{Deserialize, Serialize};
23use serde_json::{Value, json};
24
25pub const TRANSCRIPTION_MODEL: &str = "gemini-3.1-pro-preview";
27pub const RECONCILIATION_MODEL: &str = "gpt-5.6-sol";
29pub const RECONCILIATION_REASONING: &str = "xhigh";
31
32pub(super) const PIECE_CACHE_REVISION: &str = "gemini-3.1-pro-transcription-v1";
33
34const MAX_CHUNK_MILLISECONDS: u64 = 4 * 60 * 1_000;
35const CHUNK_OVERLAP_MILLISECONDS: u64 = 15 * 1_000;
36const MAX_CONCURRENT_CHUNKS: usize = 4;
37const OPUS_SAMPLE_RATE: u32 = 48_000;
38const OPUS_MAX_CHANNELS: usize = 2;
39const OPUS_BITRATE_PER_CHANNEL_BPS: u32 = 192_000;
40const MAX_PROVIDER_ATTEMPTS: u32 = 3;
41const MAX_TRANSCRIPT_TOKENS: u64 = 50_000;
42const ESTIMATED_CHARACTERS_PER_TOKEN: u64 = 4;
43const TRANSCRIPT_BREAK: &str = "<!-- KCODE_TRANSCRIPT_BREAK -->";
44
45#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
47#[serde(rename_all = "snake_case")]
48pub enum JobState {
49 Queued,
51 Running,
53 Completed,
55 Failed,
57}
58
59#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
61#[serde(tag = "kind", rename_all = "snake_case")]
62pub enum Step {
63 ValidateAudio,
65 PlanChunks,
67 TranscribeChunk {
69 index: usize,
71 total: usize,
73 },
74 ReconcileTranscript,
76 SplitTranscript,
78}
79
80#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
82#[serde(rename_all = "snake_case")]
83pub enum StepState {
84 Pending,
86 Running,
88 Retrying,
90 Completed,
92 Skipped,
94 Failed,
96}
97
98#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
100pub struct StepError {
101 pub code: String,
103 pub message: String,
105 pub retryable: bool,
107}
108
109#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
111pub struct StepStatus {
112 pub step: Step,
114 pub state: StepState,
116 pub attempts: u32,
118 pub retry_after: Option<Duration>,
120 pub error: Option<StepError>,
122}
123
124#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
126pub struct TranscriptionStatus {
127 pub state: JobState,
129 pub steps: Vec<StepStatus>,
131 pub transcript: Option<String>,
133}
134
135#[derive(Clone, Debug)]
137pub struct TranscriptionJob {
138 status: Arc<RwLock<TranscriptionStatus>>,
139}
140
141impl TranscriptionJob {
142 pub fn status(&self) -> TranscriptionStatus {
144 self.status
145 .read()
146 .unwrap_or_else(PoisonError::into_inner)
147 .clone()
148 }
149}
150
151#[derive(Clone, Debug, PartialEq)]
153pub struct AudioChunkRequest {
154 pub user_id: String,
156 pub model: String,
158 pub prompt: String,
160 pub audio_ogg: Vec<u8>,
162 pub schema: Value,
164 pub max_output_tokens: u32,
166}
167
168#[derive(Clone, Debug, Eq, PartialEq)]
170pub struct TextGenerationRequest {
171 pub user_id: String,
173 pub operation: String,
175 pub model: String,
177 pub prompt: String,
179 pub reasoning_effort: String,
181 pub timeout: Duration,
183}
184
185#[derive(Clone, Debug, Eq, PartialEq)]
187pub struct IntelligenceError {
188 message: String,
189 retryable: bool,
190}
191
192impl IntelligenceError {
193 pub fn new(message: impl Into<String>, retryable: bool) -> Self {
195 Self {
196 message: concise(&message.into(), 2_000),
197 retryable,
198 }
199 }
200
201 pub fn message(&self) -> &str {
203 &self.message
204 }
205
206 pub fn retryable(&self) -> bool {
208 self.retryable
209 }
210}
211
212pub type IntelligenceFuture =
214 Pin<Box<dyn Future<Output = Result<String, IntelligenceError>> + Send + 'static>>;
215
216pub type AudioChunkCall =
218 Arc<dyn Fn(AudioChunkRequest) -> IntelligenceFuture + Send + Sync + 'static>;
219
220pub type TextGenerationCall =
222 Arc<dyn Fn(TextGenerationRequest) -> IntelligenceFuture + Send + Sync + 'static>;
223
224#[derive(Clone)]
226pub struct AudioTranscriber {
227 transcribe_chunk: AudioChunkCall,
228 generate_text: TextGenerationCall,
229}
230
231impl std::fmt::Debug for AudioTranscriber {
232 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 formatter
234 .debug_struct("AudioTranscriber")
235 .field("transcribe_chunk", &"[CALLBACK]")
236 .field("generate_text", &"[CALLBACK]")
237 .finish()
238 }
239}
240
241pub(super) type PieceCache = Arc<dyn Fn(ChunkPlan) -> anyhow::Result<Option<String>> + Send + Sync>;
242pub(super) type PieceSink = Arc<dyn Fn(&ChunkTranscript) -> anyhow::Result<()> + Send + Sync>;
243
244impl AudioTranscriber {
245 pub fn new(transcribe_chunk: AudioChunkCall, generate_text: TextGenerationCall) -> Self {
247 Self {
248 transcribe_chunk,
249 generate_text,
250 }
251 }
252
253 pub fn transcribe(&self, user_id: impl Into<String>, audio: Vec<u8>) -> TranscriptionJob {
258 self.start(user_id.into(), audio, None, None)
259 }
260
261 pub(super) fn transcribe_durably(
262 &self,
263 user_id: String,
264 audio: Vec<u8>,
265 cache: PieceCache,
266 sink: PieceSink,
267 ) -> TranscriptionJob {
268 self.start(user_id, audio, Some(cache), Some(sink))
269 }
270
271 fn start(
272 &self,
273 user_id: String,
274 audio: Vec<u8>,
275 cache: Option<PieceCache>,
276 sink: Option<PieceSink>,
277 ) -> TranscriptionJob {
278 let status = Arc::new(RwLock::new(initial_status()));
279 let job = TranscriptionJob {
280 status: status.clone(),
281 };
282 let transcribe_chunk = self.transcribe_chunk.clone();
283 let generate_text = self.generate_text.clone();
284 match tokio::runtime::Handle::try_current() {
285 Ok(runtime) => {
286 runtime.spawn(run_job(
287 user_id,
288 audio,
289 transcribe_chunk,
290 generate_text,
291 status,
292 cache,
293 sink,
294 ));
295 }
296 Err(_) => fail_job(
297 &status,
298 &Step::ValidateAudio,
299 Failure::new(
300 "runtime_unavailable",
301 "transcribe() requires an active Tokio runtime",
302 true,
303 ),
304 ),
305 }
306 job
307 }
308}
309
310fn initial_status() -> TranscriptionStatus {
311 TranscriptionStatus {
312 state: JobState::Queued,
313 steps: vec![
314 pending(Step::ValidateAudio),
315 pending(Step::PlanChunks),
316 pending(Step::ReconcileTranscript),
317 pending(Step::SplitTranscript),
318 ],
319 transcript: None,
320 }
321}
322
323fn pending(step: Step) -> StepStatus {
324 StepStatus {
325 step,
326 state: StepState::Pending,
327 attempts: 0,
328 retry_after: None,
329 error: None,
330 }
331}
332
333#[derive(Clone, Debug)]
334struct Failure {
335 code: &'static str,
336 message: String,
337 retryable: bool,
338}
339
340impl Failure {
341 fn new(code: &'static str, message: impl Into<String>, retryable: bool) -> Self {
342 Self {
343 code,
344 message: concise(&message.into(), 2_000),
345 retryable,
346 }
347 }
348
349 fn step_error(&self) -> StepError {
350 StepError {
351 code: self.code.into(),
352 message: self.message.clone(),
353 retryable: self.retryable,
354 }
355 }
356}
357
358#[derive(Clone, Copy, Debug)]
359struct WavInfo {
360 duration_ms: u64,
361}
362
363#[derive(Clone, Copy, Debug)]
364pub(super) struct ChunkPlan {
365 pub(super) index: usize,
366 pub(super) total: usize,
367 pub(super) start_ms: u64,
368 pub(super) end_ms: u64,
369}
370
371#[derive(Clone, Debug)]
372pub(super) struct ChunkTranscript {
373 pub(super) plan: ChunkPlan,
374 pub(super) transcript: String,
375}
376
377async fn run_job(
378 user_id: String,
379 audio: Vec<u8>,
380 transcribe_chunk_call: AudioChunkCall,
381 generate_text_call: TextGenerationCall,
382 status: Arc<RwLock<TranscriptionStatus>>,
383 cache: Option<PieceCache>,
384 sink: Option<PieceSink>,
385) {
386 set_job_running(&status);
387 set_step_running(&status, &Step::ValidateAudio, 1);
388 let validation_audio = audio.clone();
389 let info = match tokio::task::spawn_blocking(move || validate_wav(&validation_audio)).await {
390 Ok(Ok(info)) => info,
391 Ok(Err(error)) => {
392 fail_job(&status, &Step::ValidateAudio, error);
393 return;
394 }
395 Err(error) => {
396 fail_job(
397 &status,
398 &Step::ValidateAudio,
399 Failure::new(
400 "validation_task_failed",
401 format!("audio validation worker stopped: {error}"),
402 true,
403 ),
404 );
405 return;
406 }
407 };
408 set_step_completed(&status, &Step::ValidateAudio);
409
410 set_step_running(&status, &Step::PlanChunks, 1);
411 let boundaries = chunk_boundaries(info.duration_ms);
412 if boundaries.is_empty() {
413 fail_job(
414 &status,
415 &Step::PlanChunks,
416 Failure::new("invalid_audio", "WAV contains no audio samples", false),
417 );
418 return;
419 }
420 let total = boundaries.len();
421 let plans = boundaries
422 .into_iter()
423 .enumerate()
424 .map(|(index, (start_ms, end_ms))| ChunkPlan {
425 index,
426 total,
427 start_ms,
428 end_ms,
429 })
430 .collect::<Vec<_>>();
431 install_chunk_steps(&status, total);
432 set_step_completed(&status, &Step::PlanChunks);
433
434 let mut chunks = vec![None; total];
435 let mut missing = Vec::with_capacity(total);
436 for plan in plans {
437 let cached = match cache.as_ref() {
438 Some(cache) => match cache(plan) {
439 Ok(value) => value,
440 Err(error) => {
441 let step = Step::TranscribeChunk {
442 index: plan.index,
443 total: plan.total,
444 };
445 fail_job(
446 &status,
447 &step,
448 Failure::new(
449 "piece_cache_failed",
450 format!(
451 "reading cached transcript for chunk {} failed: {error:#}",
452 plan.index
453 ),
454 true,
455 ),
456 );
457 return;
458 }
459 },
460 None => None,
461 };
462 if let Some(transcript) = cached {
463 let step = Step::TranscribeChunk {
464 index: plan.index,
465 total: plan.total,
466 };
467 set_step_running(&status, &step, 1);
468 chunks[plan.index] = Some(ChunkTranscript { plan, transcript });
469 set_step_completed(&status, &step);
470 } else {
471 missing.push(plan);
472 }
473 }
474
475 let shared_audio = Arc::new(audio);
476 let mut work = stream::iter(missing.into_iter().map(|plan| {
477 let audio = shared_audio.clone();
478 let transcribe_chunk_call = transcribe_chunk_call.clone();
479 let user_id = user_id.clone();
480 let status = status.clone();
481 let sink = sink.clone();
482 async move {
483 let result =
484 transcribe_chunk(&user_id, audio, transcribe_chunk_call, status, plan, sink).await;
485 (plan.index, result)
486 }
487 }))
488 .buffer_unordered(MAX_CONCURRENT_CHUNKS);
489 let mut first_failure = None;
490 while let Some((index, result)) = work.next().await {
491 match result {
492 Ok(transcript) => chunks[index] = Some(transcript),
493 Err(error) if first_failure.is_none() => first_failure = Some(error),
494 Err(_) => {}
495 }
496 }
497 if first_failure.is_some() {
498 set_job_failed(&status);
499 return;
500 }
501 let ordered = chunks.into_iter().flatten().collect::<Vec<_>>();
502 if ordered.len() != total {
503 fail_job(
504 &status,
505 &Step::ReconcileTranscript,
506 Failure::new(
507 "chunk_result_missing",
508 "a completed chunk did not produce a transcript",
509 true,
510 ),
511 );
512 return;
513 }
514
515 let prompt = reconciliation_prompt(&ordered);
516 let transcript = match generate_with_retries(
517 &generate_text_call,
518 &user_id,
519 &status,
520 Step::ReconcileTranscript,
521 prompt,
522 "reconciliation_failed",
523 )
524 .await
525 {
526 Ok(value) => value,
527 Err(_) => return,
528 };
529 let mut pieces = transcript_pieces(&transcript);
530 if pieces.is_empty() {
531 fail_job(
532 &status,
533 &Step::ReconcileTranscript,
534 Failure::new(
535 "empty_transcript",
536 "Codex returned an empty reconciled transcript",
537 true,
538 ),
539 );
540 return;
541 }
542
543 let needs_second_pass = pieces
544 .iter()
545 .any(|piece| estimate_tokens(piece) > MAX_TRANSCRIPT_TOKENS);
546 if needs_second_pass {
547 let marked = match generate_with_retries(
548 &generate_text_call,
549 &user_id,
550 &status,
551 Step::SplitTranscript,
552 split_prompt(&transcript),
553 "split_failed",
554 )
555 .await
556 {
557 Ok(value) => value,
558 Err(_) => return,
559 };
560 pieces = transcript_pieces(&marked);
561 if pieces.is_empty()
562 || pieces
563 .iter()
564 .any(|piece| estimate_tokens(piece) > MAX_TRANSCRIPT_TOKENS)
565 {
566 fail_job(
567 &status,
568 &Step::SplitTranscript,
569 Failure::new(
570 "split_invalid",
571 "Codex did not place transcript boundaries below the size limit",
572 true,
573 ),
574 );
575 return;
576 }
577 } else if pieces.len() > 1 {
578 set_step_running(&status, &Step::SplitTranscript, 1);
579 set_step_completed(&status, &Step::SplitTranscript);
580 } else {
581 set_step_skipped(&status, &Step::SplitTranscript);
582 }
583
584 let final_transcript = pieces.join("\n\n");
585 let mut snapshot = status.write().unwrap_or_else(PoisonError::into_inner);
586 snapshot.transcript = Some(final_transcript);
587 snapshot.state = JobState::Completed;
588}
589
590async fn transcribe_chunk(
591 user_id: &str,
592 audio: Arc<Vec<u8>>,
593 transcribe_chunk_call: AudioChunkCall,
594 status: Arc<RwLock<TranscriptionStatus>>,
595 plan: ChunkPlan,
596 sink: Option<PieceSink>,
597) -> Result<ChunkTranscript, Failure> {
598 let step = Step::TranscribeChunk {
599 index: plan.index,
600 total: plan.total,
601 };
602 set_step_running(&status, &step, 1);
603 let prepared = tokio::task::spawn_blocking(move || {
604 wav_interval_to_opus(&audio, plan.start_ms, plan.end_ms)
605 })
606 .await
607 .map_err(|error| {
608 Failure::new(
609 "audio_task_failed",
610 format!("chunk {} audio worker stopped: {error}", plan.index),
611 true,
612 )
613 })?
614 .map_err(|error| {
615 Failure::new(
616 "audio_preparation_failed",
617 format!("chunk {} could not be prepared: {error:#}", plan.index),
618 false,
619 )
620 });
621 let opus = match prepared {
622 Ok(value) => value,
623 Err(error) => {
624 fail_step(&status, &step, &error);
625 return Err(error);
626 }
627 };
628
629 for attempt in 1..=MAX_PROVIDER_ATTEMPTS {
630 set_step_running(&status, &step, attempt);
631 let result = request_chunk_transcript(user_id, &transcribe_chunk_call, &opus, plan).await;
632 match result {
633 Ok(transcript) => {
634 let completed = ChunkTranscript { plan, transcript };
635 if let Some(sink) = sink.as_ref()
636 && let Err(error) = sink(&completed)
637 {
638 let failure = Failure::new(
639 "piece_persistence_failed",
640 format!(
641 "persisting transcript for chunk {} failed: {error:#}",
642 plan.index
643 ),
644 true,
645 );
646 fail_step(&status, &step, &failure);
647 return Err(failure);
648 }
649 set_step_completed(&status, &step);
650 return Ok(completed);
651 }
652 Err(error) if error.retryable && attempt < MAX_PROVIDER_ATTEMPTS => {
653 let delay = retry_delay(attempt);
654 set_step_retrying(&status, &step, attempt, delay, &error);
655 tokio::time::sleep(delay).await;
656 }
657 Err(error) => {
658 fail_step(&status, &step, &error);
659 return Err(error);
660 }
661 }
662 }
663 unreachable!("provider attempt loop always returns")
664}
665
666async fn request_chunk_transcript(
667 user_id: &str,
668 transcribe_chunk: &AudioChunkCall,
669 opus: &[u8],
670 plan: ChunkPlan,
671) -> Result<String, Failure> {
672 let text = transcribe_chunk(AudioChunkRequest {
673 user_id: user_id.to_owned(),
674 model: TRANSCRIPTION_MODEL.into(),
675 prompt: transcription_prompt(plan),
676 audio_ogg: opus.to_vec(),
677 schema: transcription_schema(),
678 max_output_tokens: 32_768,
679 })
680 .await
681 .map_err(|error| {
682 Failure::new(
683 "intelligence_failed",
684 format!("structured chunk transcription failed: {}", error.message()),
685 error.retryable(),
686 )
687 })?;
688 let transcript: Value = serde_json::from_str(&text).map_err(|error| {
689 Failure::new(
690 "gemini_response_invalid",
691 format!("Gemini returned invalid structured transcript JSON: {error}"),
692 true,
693 )
694 })?;
695 if transcript
696 .get("utterances")
697 .and_then(Value::as_array)
698 .is_none()
699 {
700 return Err(Failure::new(
701 "gemini_response_invalid",
702 "Gemini transcript omitted the utterances array",
703 true,
704 ));
705 }
706 serde_json::to_string_pretty(&transcript).map_err(|error| {
707 Failure::new(
708 "gemini_response_invalid",
709 format!("serializing Gemini transcript: {error}"),
710 true,
711 )
712 })
713}
714
715async fn generate_with_retries(
716 generate_text: &TextGenerationCall,
717 user_id: &str,
718 status: &Arc<RwLock<TranscriptionStatus>>,
719 step: Step,
720 prompt: String,
721 code: &'static str,
722) -> Result<String, Failure> {
723 for attempt in 1..=MAX_PROVIDER_ATTEMPTS {
724 set_step_running(status, &step, attempt);
725 let operation = match &step {
726 Step::ReconcileTranscript => "reconcile_transcript",
727 Step::SplitTranscript => "split_transcript",
728 _ => "process_transcript",
729 };
730 match generate_text(TextGenerationRequest {
731 user_id: user_id.to_owned(),
732 operation: operation.into(),
733 model: RECONCILIATION_MODEL.into(),
734 prompt: prompt.clone(),
735 reasoning_effort: RECONCILIATION_REASONING.into(),
736 timeout: Duration::from_secs(30 * 60),
737 })
738 .await
739 {
740 Ok(response) => {
741 if response.trim().is_empty() {
742 if attempt < MAX_PROVIDER_ATTEMPTS {
743 let error =
744 Failure::new(code, "Codex returned empty transcript text", true);
745 let delay = retry_delay(attempt);
746 set_step_retrying(status, &step, attempt, delay, &error);
747 tokio::time::sleep(delay).await;
748 continue;
749 }
750 let error = Failure::new(code, "Codex returned empty transcript text", true);
751 fail_job(status, &step, error.clone());
752 return Err(error);
753 }
754 set_step_completed(status, &step);
755 return Ok(response);
756 }
757 Err(provider) if provider.retryable() && attempt < MAX_PROVIDER_ATTEMPTS => {
758 let error = Failure::new(
759 code,
760 format!(
761 "intelligence transcript processing failed: {}",
762 provider.message()
763 ),
764 true,
765 );
766 let delay = retry_delay(attempt);
767 set_step_retrying(status, &step, attempt, delay, &error);
768 tokio::time::sleep(delay).await;
769 }
770 Err(provider) => {
771 let error = Failure::new(
772 code,
773 format!(
774 "intelligence transcript processing failed: {}",
775 provider.message()
776 ),
777 provider.retryable(),
778 );
779 fail_job(status, &step, error.clone());
780 return Err(error);
781 }
782 }
783 }
784 unreachable!("provider attempt loop always returns")
785}
786
787fn retry_delay(attempt: u32) -> Duration {
788 Duration::from_secs(60 * (1_u64 << attempt.saturating_sub(1).min(5)))
789}
790
791fn validate_wav(audio: &[u8]) -> Result<WavInfo, Failure> {
792 if audio.is_empty() {
793 return Err(Failure::new(
794 "invalid_audio",
795 "audio byte buffer is empty",
796 false,
797 ));
798 }
799 let reader = WavReader::new(Cursor::new(audio)).map_err(|error| {
800 Failure::new(
801 "invalid_audio",
802 format!("invalid WAV recording: {error}"),
803 false,
804 )
805 })?;
806 let spec = reader.spec();
807 if spec.sample_rate == 0 || !(1..=OPUS_MAX_CHANNELS as u16).contains(&spec.channels) {
808 return Err(Failure::new(
809 "invalid_audio",
810 "WAV must have a positive sample rate and one or two channels",
811 false,
812 ));
813 }
814 let supported = matches!(
815 (spec.sample_format, spec.bits_per_sample),
816 (SampleFormat::Float, 32) | (SampleFormat::Int, 1..=32)
817 );
818 if !supported {
819 return Err(Failure::new(
820 "invalid_audio",
821 format!(
822 "unsupported WAV sample format: {:?} with {} bits",
823 spec.sample_format, spec.bits_per_sample
824 ),
825 false,
826 ));
827 }
828 let declared_audio_bytes = u64::from(reader.duration())
829 .saturating_mul(u64::from(spec.channels))
830 .saturating_mul(u64::from(spec.bits_per_sample).div_ceil(8));
831 if declared_audio_bytes > audio.len() as u64 {
832 return Err(Failure::new(
833 "invalid_audio",
834 format!(
835 "invalid WAV recording: header declares {declared_audio_bytes} audio bytes but the buffer has only {} bytes",
836 audio.len()
837 ),
838 false,
839 ));
840 }
841 let duration_ms = (u64::from(reader.duration()) * 1_000).div_ceil(u64::from(spec.sample_rate));
842 if duration_ms == 0 {
843 return Err(Failure::new(
844 "invalid_audio",
845 "WAV contains no audio samples",
846 false,
847 ));
848 }
849 Ok(WavInfo { duration_ms })
850}
851
852fn chunk_boundaries(duration_ms: u64) -> Vec<(u64, u64)> {
853 if duration_ms == 0 {
854 return Vec::new();
855 }
856 if duration_ms <= MAX_CHUNK_MILLISECONDS {
857 return vec![(0, duration_ms)];
858 }
859 let advance = MAX_CHUNK_MILLISECONDS - CHUNK_OVERLAP_MILLISECONDS;
860 let chunks = (duration_ms - CHUNK_OVERLAP_MILLISECONDS).div_ceil(advance);
861 let window = (duration_ms + (chunks - 1) * CHUNK_OVERLAP_MILLISECONDS).div_ceil(chunks);
862 let step = window - CHUNK_OVERLAP_MILLISECONDS;
863 (0..chunks)
864 .map(|index| {
865 let start = index * step;
866 (start, (start + window).min(duration_ms))
867 })
868 .filter(|(start, end)| end > start)
869 .collect()
870}
871
872fn wav_interval_to_opus(audio: &[u8], start_ms: u64, end_ms: u64) -> anyhow::Result<Vec<u8>> {
873 let mut reader = WavReader::new(Cursor::new(audio)).context("opening in-memory WAV audio")?;
874 let spec = reader.spec();
875 ensure!(end_ms > start_ms, "audio interval is empty");
876 let start_frame = u32::try_from(start_ms * u64::from(spec.sample_rate) / 1_000)
877 .context("audio interval starts beyond WAV limits")?;
878 let end_frame = u32::try_from(end_ms * u64::from(spec.sample_rate) / 1_000)
879 .context("audio interval ends beyond WAV limits")?
880 .min(reader.duration());
881 let sample_values = usize::try_from(
882 u64::from(end_frame.saturating_sub(start_frame)) * u64::from(spec.channels),
883 )
884 .context("audio interval is too large for this platform")?;
885 reader.seek(start_frame).context("seeking WAV interval")?;
886 let samples = match (spec.sample_format, spec.bits_per_sample) {
887 (SampleFormat::Float, 32) => reader
888 .samples::<f32>()
889 .take(sample_values)
890 .map(|sample| sample.context("reading 32-bit float WAV sample"))
891 .collect::<anyhow::Result<Vec<_>>>()?,
892 (SampleFormat::Int, 1..=8) => {
893 let scale = 2.0_f32.powi(i32::from(spec.bits_per_sample) - 1);
894 reader
895 .samples::<i8>()
896 .take(sample_values)
897 .map(|sample| {
898 sample
899 .map(|value| f32::from(value) / scale)
900 .context("reading 8-bit WAV sample")
901 })
902 .collect::<anyhow::Result<Vec<_>>>()?
903 }
904 (SampleFormat::Int, 9..=16) => {
905 let scale = 2.0_f32.powi(i32::from(spec.bits_per_sample) - 1);
906 reader
907 .samples::<i16>()
908 .take(sample_values)
909 .map(|sample| {
910 sample
911 .map(|value| f32::from(value) / scale)
912 .context("reading 16-bit WAV sample")
913 })
914 .collect::<anyhow::Result<Vec<_>>>()?
915 }
916 (SampleFormat::Int, 17..=32) => {
917 let scale = 2.0_f64.powi(i32::from(spec.bits_per_sample) - 1) as f32;
918 reader
919 .samples::<i32>()
920 .take(sample_values)
921 .map(|sample| {
922 sample
923 .map(|value| value as f32 / scale)
924 .context("reading high-resolution integer WAV sample")
925 })
926 .collect::<anyhow::Result<Vec<_>>>()?
927 }
928 _ => anyhow::bail!(
929 "unsupported WAV sample format: {:?} with {} bits",
930 spec.sample_format,
931 spec.bits_per_sample
932 ),
933 };
934 ensure!(
935 samples.len() == sample_values,
936 "WAV audio ended before the planned interval"
937 );
938 let channels = usize::from(spec.channels);
939 ensure!(
940 samples.len().is_multiple_of(channels),
941 "WAV audio ended with an incomplete frame"
942 );
943 ensure!(
944 !samples.is_empty(),
945 "WAV audio interval contains no samples"
946 );
947 ensure!(
948 samples.iter().all(|sample| sample.is_finite()),
949 "WAV audio contains a non-finite sample"
950 );
951 let pcm = samples
952 .into_iter()
953 .map(|sample| sample.clamp(-1.0, 1.0))
954 .collect::<Vec<_>>();
955 let pcm = resample_interleaved(&pcm, spec.sample_rate, channels)?;
956 let bitrate = OPUS_BITRATE_PER_CHANNEL_BPS * u32::from(spec.channels);
957 Ok(encode_ogg_opus(&pcm, channels, bitrate))
958}
959
960fn resample_interleaved(
961 source: &[f32],
962 source_rate: u32,
963 channels: usize,
964) -> anyhow::Result<Vec<f32>> {
965 ensure!(source_rate > 0, "WAV sample rate must be positive");
966 ensure!(
967 (1..=OPUS_MAX_CHANNELS).contains(&channels),
968 "Ogg Opus encoding supports mono or stereo PCM"
969 );
970 ensure!(
971 source.len().is_multiple_of(channels),
972 "PCM ended with an incomplete frame"
973 );
974 ensure!(!source.is_empty(), "PCM contains no samples");
975 if source_rate == OPUS_SAMPLE_RATE {
976 return Ok(source.to_vec());
977 }
978 let source_frames = source.len() / channels;
979 let output_frames = usize::try_from(
980 (source_frames as u128 * u128::from(OPUS_SAMPLE_RATE)).div_ceil(u128::from(source_rate)),
981 )
982 .context("resampled audio is too large for this platform")?;
983 let output_samples = output_frames
984 .checked_mul(channels)
985 .context("resampled audio is too large for this platform")?;
986 let mut output = Vec::with_capacity(output_samples);
987 for output_frame in 0..output_frames {
988 let source_position = output_frame as u128 * u128::from(source_rate);
989 let lower = usize::try_from(source_position / u128::from(OPUS_SAMPLE_RATE))
990 .context("resampling position is too large for this platform")?
991 .min(source_frames - 1);
992 let upper = (lower + 1).min(source_frames - 1);
993 let fraction =
994 (source_position % u128::from(OPUS_SAMPLE_RATE)) as f32 / OPUS_SAMPLE_RATE as f32;
995 for channel in 0..channels {
996 let lower_sample = source[lower * channels + channel];
997 let upper_sample = source[upper * channels + channel];
998 output.push(lower_sample + (upper_sample - lower_sample) * fraction);
999 }
1000 }
1001 Ok(output)
1002}
1003
1004fn transcription_prompt(plan: ChunkPlan) -> String {
1005 format!(
1006 "Transcribe this audio faithfully and completely. Distinguish every discernible speaker with chunk-local labels such as speaker_1. Do not guess a real identity. Preserve the original language. When an utterance is not English, also provide an accurate English translation; for English, use an empty translation string. Add concise annotations when speech is unclear, overlapping, interrupted, emotional in a materially relevant way, or accompanied by relevant non-speech audio. Timestamps are seconds relative to this audio chunk. Do not omit quiet or difficult portions.\n\nChunk index: {} of {}\nThis chunk covers source offsets {:.3} through {:.3} seconds. Adjacent chunks overlap by up to 15 seconds; transcribe the entire supplied chunk even when boundary material will be repeated elsewhere.",
1007 plan.index + 1,
1008 plan.total,
1009 plan.start_ms as f64 / 1_000.0,
1010 plan.end_ms as f64 / 1_000.0,
1011 )
1012}
1013
1014fn transcription_schema() -> Value {
1015 json!({
1016 "type":"object",
1017 "properties":{
1018 "utterances":{
1019 "type":"array",
1020 "items":{
1021 "type":"object",
1022 "properties":{
1023 "start_seconds":{"type":"number"},
1024 "end_seconds":{"type":"number"},
1025 "speaker":{"type":"string"},
1026 "language":{"type":"string"},
1027 "original_text":{"type":"string"},
1028 "english_translation":{"type":"string"},
1029 "annotations":{"type":"array","items":{"type":"string"}},
1030 "confidence":{"type":"string","enum":["high","medium","low"]}
1031 },
1032 "required":["start_seconds","end_seconds","speaker","language","original_text","english_translation","annotations","confidence"]
1033 }
1034 },
1035 "chunk_notes":{"type":"array","items":{"type":"string"}}
1036 },
1037 "required":["utterances","chunk_notes"]
1038 })
1039}
1040
1041fn reconciliation_prompt(chunks: &[ChunkTranscript]) -> String {
1042 let mut prompt = format!(
1043 "You are producing the canonical final transcript of one audio recording. The chunk transcripts below are already in exact chronological order. They were independently transcribed from audio windows that overlap their neighbors by 15 seconds. Faithfully copy all spoken content into one coherent transcript, remove only duplicated boundary material, and reconcile chunk-local speaker labels across the complete conversation. Use real speaker names only when supported by the conversation; otherwise assign stable labels such as Speaker A. Preserve useful uncertainty and annotations. For every non-English utterance, show its English translation alongside it. Preserve chronological timestamps, converting chunk-relative timestamps using each chunk's supplied source offset. Do not summarize or omit content.\n\nOutput only the final readable Markdown transcript. When the transcript would exceed an estimated 50,000 tokens using one token per four Unicode characters, insert the exact line `{TRANSCRIPT_BREAK}` at sensible conversational or topical boundaries so every resulting piece stays at or below that estimate. Do not make pieces equal-sized merely for symmetry.\n\nORDERED CHUNK TRANSCRIPTS\n"
1044 );
1045 for chunk in chunks {
1046 prompt.push_str(&format!(
1047 "\n\nCHUNK {:05} | source offsets {:.3}–{:.3} seconds\n{}",
1048 chunk.plan.index,
1049 chunk.plan.start_ms as f64 / 1_000.0,
1050 chunk.plan.end_ms as f64 / 1_000.0,
1051 chunk.transcript,
1052 ));
1053 }
1054 prompt
1055}
1056
1057fn split_prompt(transcript: &str) -> String {
1058 format!(
1059 "Copy the following final transcript completely and exactly, adding only the exact boundary line `{TRANSCRIPT_BREAK}` at sensible conversational or topical boundaries. Using the conservative estimate of one token per four Unicode characters, every resulting piece must be no more than 50,000 estimated tokens. Do not summarize, rewrite, reorder, or omit anything. Output only the complete marked transcript.\n\nFINAL TRANSCRIPT\n\n{transcript}"
1060 )
1061}
1062
1063fn transcript_pieces(transcript: &str) -> Vec<String> {
1064 transcript
1065 .split(TRANSCRIPT_BREAK)
1066 .map(str::trim)
1067 .filter(|piece| !piece.is_empty())
1068 .map(str::to_owned)
1069 .collect()
1070}
1071
1072fn estimate_tokens(value: &str) -> u64 {
1073 (value.chars().count() as u64).div_ceil(ESTIMATED_CHARACTERS_PER_TOKEN)
1074}
1075
1076fn set_job_running(status: &Arc<RwLock<TranscriptionStatus>>) {
1077 status.write().unwrap_or_else(PoisonError::into_inner).state = JobState::Running;
1078}
1079
1080fn set_job_failed(status: &Arc<RwLock<TranscriptionStatus>>) {
1081 status.write().unwrap_or_else(PoisonError::into_inner).state = JobState::Failed;
1082}
1083
1084fn install_chunk_steps(status: &Arc<RwLock<TranscriptionStatus>>, total: usize) {
1085 let mut snapshot = status.write().unwrap_or_else(PoisonError::into_inner);
1086 let insertion = snapshot
1087 .steps
1088 .iter()
1089 .position(|entry| entry.step == Step::ReconcileTranscript)
1090 .expect("initial status contains reconciliation");
1091 snapshot.steps.splice(
1092 insertion..insertion,
1093 (0..total).map(|index| pending(Step::TranscribeChunk { index, total })),
1094 );
1095}
1096
1097fn mutate_step(
1098 status: &Arc<RwLock<TranscriptionStatus>>,
1099 step: &Step,
1100 change: impl FnOnce(&mut StepStatus),
1101) {
1102 let mut snapshot = status.write().unwrap_or_else(PoisonError::into_inner);
1103 if let Some(entry) = snapshot.steps.iter_mut().find(|entry| &entry.step == step) {
1104 change(entry);
1105 }
1106}
1107
1108fn set_step_running(status: &Arc<RwLock<TranscriptionStatus>>, step: &Step, attempt: u32) {
1109 mutate_step(status, step, |entry| {
1110 entry.state = StepState::Running;
1111 entry.attempts = attempt;
1112 entry.retry_after = None;
1113 entry.error = None;
1114 });
1115}
1116
1117fn set_step_retrying(
1118 status: &Arc<RwLock<TranscriptionStatus>>,
1119 step: &Step,
1120 attempt: u32,
1121 delay: Duration,
1122 error: &Failure,
1123) {
1124 mutate_step(status, step, |entry| {
1125 entry.state = StepState::Retrying;
1126 entry.attempts = attempt;
1127 entry.retry_after = Some(delay);
1128 entry.error = Some(error.step_error());
1129 });
1130}
1131
1132fn set_step_completed(status: &Arc<RwLock<TranscriptionStatus>>, step: &Step) {
1133 mutate_step(status, step, |entry| {
1134 entry.state = StepState::Completed;
1135 entry.retry_after = None;
1136 entry.error = None;
1137 });
1138}
1139
1140fn set_step_skipped(status: &Arc<RwLock<TranscriptionStatus>>, step: &Step) {
1141 mutate_step(status, step, |entry| {
1142 entry.state = StepState::Skipped;
1143 entry.retry_after = None;
1144 entry.error = None;
1145 });
1146}
1147
1148fn fail_step(status: &Arc<RwLock<TranscriptionStatus>>, step: &Step, error: &Failure) {
1149 mutate_step(status, step, |entry| {
1150 entry.state = StepState::Failed;
1151 entry.retry_after = None;
1152 entry.error = Some(error.step_error());
1153 });
1154}
1155
1156fn fail_job(status: &Arc<RwLock<TranscriptionStatus>>, step: &Step, error: Failure) {
1157 fail_step(status, step, &error);
1158 set_job_failed(status);
1159}
1160
1161fn concise(value: &str, limit: usize) -> String {
1162 let clean = value.split_whitespace().collect::<Vec<_>>().join(" ");
1163 clean.chars().take(limit).collect()
1164}
1165
1166#[cfg(test)]
1167mod tests {
1168 use super::*;
1169 use hound::{WavSpec, WavWriter};
1170
1171 fn wav_bytes(channels: u16, sample_rate: u32, frames: u32) -> Vec<u8> {
1172 let mut cursor = Cursor::new(Vec::new());
1173 {
1174 let mut writer = WavWriter::new(
1175 &mut cursor,
1176 WavSpec {
1177 channels,
1178 sample_rate,
1179 bits_per_sample: 16,
1180 sample_format: SampleFormat::Int,
1181 },
1182 )
1183 .unwrap();
1184 for frame in 0..frames {
1185 let phase = frame as f32 * 440.0 * std::f32::consts::TAU / sample_rate as f32;
1186 for _ in 0..channels {
1187 writer.write_sample((phase.sin() * 8_192.0) as i16).unwrap();
1188 }
1189 }
1190 writer.finalize().unwrap();
1191 }
1192 cursor.into_inner()
1193 }
1194
1195 #[test]
1196 fn long_recordings_are_equalized_with_overlap() {
1197 let chunks = chunk_boundaries(8 * 60 * 1_000);
1198 assert_eq!(chunks.len(), 3);
1199 assert!(
1200 chunks
1201 .iter()
1202 .all(|(start, end)| end - start <= MAX_CHUNK_MILLISECONDS)
1203 );
1204 assert_eq!(chunks[0].1 - chunks[1].0, CHUNK_OVERLAP_MILLISECONDS);
1205 assert_eq!(chunks[1].1 - chunks[2].0, CHUNK_OVERLAP_MILLISECONDS);
1206 assert!((chunks[0].1 - chunks[0].0).abs_diff(chunks[2].1 - chunks[2].0) <= 1);
1207 }
1208
1209 #[test]
1210 fn interval_encoding_is_entirely_in_memory() {
1211 let wav = wav_bytes(2, 44_100, 4_410);
1212 validate_wav(&wav).unwrap();
1213 let opus = wav_interval_to_opus(&wav, 0, 100).unwrap();
1214 assert_eq!(&opus[..4], b"OggS");
1215 let (decoded, head) = ruopus::decode_ogg_opus(&opus).unwrap();
1216 assert_eq!(head.channel_count, 2);
1217 assert_eq!(head.input_sample_rate, OPUS_SAMPLE_RATE);
1218 assert!(!decoded.is_empty());
1219 }
1220
1221 #[test]
1222 fn duration_rounds_up_to_cover_the_final_sample() {
1223 let wav = wav_bytes(1, 48_000, 49);
1224 assert_eq!(validate_wav(&wav).unwrap().duration_ms, 2);
1225 }
1226
1227 #[test]
1228 fn initial_status_is_serializable_and_queued() {
1229 let status = initial_status();
1230 assert_eq!(status.state, JobState::Queued);
1231 assert_eq!(status.steps.len(), 4);
1232 let serialized = serde_json::to_string(&status).unwrap();
1233 let restored: TranscriptionStatus = serde_json::from_str(&serialized).unwrap();
1234 assert_eq!(restored, status);
1235 }
1236
1237 #[test]
1238 fn invalid_wav_is_nonretryable() {
1239 let error = validate_wav(b"not a wav").unwrap_err();
1240 assert_eq!(error.code, "invalid_audio");
1241 assert!(!error.retryable);
1242 }
1243
1244 #[test]
1245 fn transcript_breaks_are_removed_from_public_output() {
1246 let pieces = transcript_pieces(&format!("first\n{TRANSCRIPT_BREAK}\nsecond"));
1247 assert_eq!(pieces, vec!["first", "second"]);
1248 }
1249}