Skip to main content

kcode_audio_ingress/
transcribe.rs

1//! In-memory, job-oriented audio transcription using caller-owned intelligence calls.
2//!
3//! [`AudioTranscriber::transcribe`] starts the complete public in-memory
4//! pipeline. AudioIngress uses the same pipeline with private durable piece
5//! cache callbacks.
6
7#![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
25/// Gemini model used for chunk transcription.
26pub const TRANSCRIPTION_MODEL: &str = "gemini-3.1-pro-preview";
27/// Codex model used to reconcile ordered chunk transcripts.
28pub const RECONCILIATION_MODEL: &str = "gpt-5.6-sol";
29/// Codex reasoning setting used for transcript reconciliation.
30pub 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/// Overall state of an in-memory transcription job.
46#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
47#[serde(rename_all = "snake_case")]
48pub enum JobState {
49    /// The background task has not begun processing.
50    Queued,
51    /// At least one pipeline step is active or retrying.
52    Running,
53    /// Every required step completed and `transcript` is present.
54    Completed,
55    /// A terminal step error prevented completion.
56    Failed,
57}
58
59/// One ordered pipeline operation.
60#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
61#[serde(tag = "kind", rename_all = "snake_case")]
62pub enum Step {
63    /// Validate the supplied WAV byte buffer.
64    ValidateAudio,
65    /// Calculate equalized overlapping audio windows.
66    PlanChunks,
67    /// Prepare, submit, and validate one chronological audio chunk.
68    TranscribeChunk {
69        /// Zero-based chronological chunk index.
70        index: usize,
71        /// Total number of planned chunks.
72        total: usize,
73    },
74    /// Reconcile all ordered chunk transcripts into canonical Markdown.
75    ReconcileTranscript,
76    /// Add safe boundaries when the reconciled transcript is unusually large.
77    SplitTranscript,
78}
79
80/// State of one pipeline step.
81#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
82#[serde(rename_all = "snake_case")]
83pub enum StepState {
84    /// A dependency has not completed yet.
85    Pending,
86    /// The step is currently executing.
87    Running,
88    /// A retryable provider operation is waiting before another attempt.
89    Retrying,
90    /// The step completed successfully.
91    Completed,
92    /// The step was unnecessary for this input.
93    Skipped,
94    /// The step ended with an error.
95    Failed,
96}
97
98/// Sanitized terminal or retryable step error.
99#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
100pub struct StepError {
101    /// Stable machine-readable error category.
102    pub code: String,
103    /// Concise human-readable diagnostic.
104    pub message: String,
105    /// Whether starting or continuing a retry can reasonably succeed.
106    pub retryable: bool,
107}
108
109/// Current status of one ordered pipeline step.
110#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
111pub struct StepStatus {
112    /// Operation represented by this entry.
113    pub step: Step,
114    /// Current lifecycle state.
115    pub state: StepState,
116    /// Number of times the operation has started.
117    pub attempts: u32,
118    /// Remaining scheduled retry delay when the snapshot was written.
119    pub retry_after: Option<Duration>,
120    /// Current failure detail, if any.
121    pub error: Option<StepError>,
122}
123
124/// Cheap cloneable snapshot returned by [`TranscriptionJob::status`].
125#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
126pub struct TranscriptionStatus {
127    /// Overall job lifecycle state.
128    pub state: JobState,
129    /// Ordered pipeline operations, including one entry per planned chunk.
130    pub steps: Vec<StepStatus>,
131    /// Final canonical Markdown transcript, present only after completion.
132    pub transcript: Option<String>,
133}
134
135/// Cloneable handle for polling one in-memory transcription.
136#[derive(Clone, Debug)]
137pub struct TranscriptionJob {
138    status: Arc<RwLock<TranscriptionStatus>>,
139}
140
141impl TranscriptionJob {
142    /// Returns an in-memory snapshot without performing I/O or provider calls.
143    pub fn status(&self) -> TranscriptionStatus {
144        self.status
145            .read()
146            .unwrap_or_else(PoisonError::into_inner)
147            .clone()
148    }
149}
150
151/// One structured audio request delegated to the application intelligence router.
152#[derive(Clone, Debug, PartialEq)]
153pub struct AudioChunkRequest {
154    /// Stable application user identifier charged for the call.
155    pub user_id: String,
156    /// Exact model identifier.
157    pub model: String,
158    /// Exact model-visible transcription prompt.
159    pub prompt: String,
160    /// Complete Ogg Opus chunk bytes.
161    pub audio_ogg: Vec<u8>,
162    /// JSON Schema required for the model response.
163    pub schema: Value,
164    /// Maximum provider output tokens.
165    pub max_output_tokens: u32,
166}
167
168/// One tool-free text request delegated to the application intelligence router.
169#[derive(Clone, Debug, Eq, PartialEq)]
170pub struct TextGenerationRequest {
171    /// Stable application user identifier charged for the call.
172    pub user_id: String,
173    /// Receipt operation such as `reconcile_transcript`.
174    pub operation: String,
175    /// Exact model identifier.
176    pub model: String,
177    /// Exact model-visible prompt.
178    pub prompt: String,
179    /// Exact reasoning setting.
180    pub reasoning_effort: String,
181    /// Maximum wall-clock duration.
182    pub timeout: Duration,
183}
184
185/// Sanitized intelligence-router failure returned to the audio workflow.
186#[derive(Clone, Debug, Eq, PartialEq)]
187pub struct IntelligenceError {
188    message: String,
189    retryable: bool,
190}
191
192impl IntelligenceError {
193    /// Constructs one bounded backend failure.
194    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    /// Returns the sanitized failure detail.
202    pub fn message(&self) -> &str {
203        &self.message
204    }
205
206    /// Whether retrying the model call can reasonably succeed.
207    pub fn retryable(&self) -> bool {
208        self.retryable
209    }
210}
211
212/// Sendable future returned by an injected intelligence call.
213pub type IntelligenceFuture =
214    Pin<Box<dyn Future<Output = Result<String, IntelligenceError>> + Send + 'static>>;
215
216/// Typed structured-audio call implemented by the application intelligence router.
217pub type AudioChunkCall =
218    Arc<dyn Fn(AudioChunkRequest) -> IntelligenceFuture + Send + Sync + 'static>;
219
220/// Typed tool-free text call implemented by the application intelligence router.
221pub type TextGenerationCall =
222    Arc<dyn Fn(TextGenerationRequest) -> IntelligenceFuture + Send + Sync + 'static>;
223
224/// Complete audio workflow backed by caller-owned typed intelligence operations.
225#[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    /// Constructs a transcriber from typed intelligence-router calls.
246    pub fn new(transcribe_chunk: AudioChunkCall, generate_text: TextGenerationCall) -> Self {
247        Self {
248            transcribe_chunk,
249            generate_text,
250        }
251    }
252
253    /// Starts transcription of owned WAV bytes and immediately returns a job.
254    ///
255    /// The complete pipeline runs on the current Tokio runtime. If no runtime
256    /// is active, the returned job is immediately failed with a status error.
257    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}