Skip to main content

kcode_audio_ingress/
transcribe.rs

1//! In-memory, job-oriented audio transcription using caller-owned provider clients.
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    io::Cursor,
12    sync::{Arc, PoisonError, RwLock},
13    time::Duration,
14};
15
16use anyhow::{Context, ensure};
17use futures::{StreamExt, stream};
18use hound::{SampleFormat, WavReader};
19use kcode_codex_runtime::{Codex, ErrorKind as CodexErrorKind, GenerationRequest, ReasoningEffort};
20use kcode_gemini_api::{
21    CompletionStatus, Error as GeminiError, Gemini, GenerationOptions, MediaInput,
22    MultimodalRequest, ServiceTier, StructuredOutput, ThinkingLevel,
23};
24use ruopus::encode_ogg_opus;
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27
28/// Gemini model used for chunk transcription.
29pub const TRANSCRIPTION_MODEL: &str = kcode_gemini_api::GEMINI_31_PRO;
30/// Codex model used to reconcile ordered chunk transcripts.
31pub const RECONCILIATION_MODEL: &str = "gpt-5.6-sol";
32/// Codex reasoning setting used for transcript reconciliation.
33pub const RECONCILIATION_REASONING: &str = "xhigh";
34
35pub(super) const PIECE_CACHE_REVISION: &str = "gemini-3.1-pro-transcription-v1";
36
37const MAX_CHUNK_MILLISECONDS: u64 = 4 * 60 * 1_000;
38const CHUNK_OVERLAP_MILLISECONDS: u64 = 15 * 1_000;
39const MAX_CONCURRENT_CHUNKS: usize = 4;
40const OPUS_SAMPLE_RATE: u32 = 48_000;
41const OPUS_MAX_CHANNELS: usize = 2;
42const OPUS_BITRATE_PER_CHANNEL_BPS: u32 = 192_000;
43const MAX_PROVIDER_ATTEMPTS: u32 = 3;
44const MAX_TRANSCRIPT_TOKENS: u64 = 50_000;
45const ESTIMATED_CHARACTERS_PER_TOKEN: u64 = 4;
46const TRANSCRIPT_BREAK: &str = "<!-- KCODE_TRANSCRIPT_BREAK -->";
47
48/// Overall state of an in-memory transcription job.
49#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
50#[serde(rename_all = "snake_case")]
51pub enum JobState {
52    /// The background task has not begun processing.
53    Queued,
54    /// At least one pipeline step is active or retrying.
55    Running,
56    /// Every required step completed and `transcript` is present.
57    Completed,
58    /// A terminal step error prevented completion.
59    Failed,
60}
61
62/// One ordered pipeline operation.
63#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
64#[serde(tag = "kind", rename_all = "snake_case")]
65pub enum Step {
66    /// Validate the supplied WAV byte buffer.
67    ValidateAudio,
68    /// Calculate equalized overlapping audio windows.
69    PlanChunks,
70    /// Prepare, submit, and validate one chronological audio chunk.
71    TranscribeChunk {
72        /// Zero-based chronological chunk index.
73        index: usize,
74        /// Total number of planned chunks.
75        total: usize,
76    },
77    /// Reconcile all ordered chunk transcripts into canonical Markdown.
78    ReconcileTranscript,
79    /// Add safe boundaries when the reconciled transcript is unusually large.
80    SplitTranscript,
81}
82
83/// State of one pipeline step.
84#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
85#[serde(rename_all = "snake_case")]
86pub enum StepState {
87    /// A dependency has not completed yet.
88    Pending,
89    /// The step is currently executing.
90    Running,
91    /// A retryable provider operation is waiting before another attempt.
92    Retrying,
93    /// The step completed successfully.
94    Completed,
95    /// The step was unnecessary for this input.
96    Skipped,
97    /// The step ended with an error.
98    Failed,
99}
100
101/// Sanitized terminal or retryable step error.
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
103pub struct StepError {
104    /// Stable machine-readable error category.
105    pub code: String,
106    /// Concise human-readable diagnostic.
107    pub message: String,
108    /// Whether starting or continuing a retry can reasonably succeed.
109    pub retryable: bool,
110}
111
112/// Current status of one ordered pipeline step.
113#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
114pub struct StepStatus {
115    /// Operation represented by this entry.
116    pub step: Step,
117    /// Current lifecycle state.
118    pub state: StepState,
119    /// Number of times the operation has started.
120    pub attempts: u32,
121    /// Remaining scheduled retry delay when the snapshot was written.
122    pub retry_after: Option<Duration>,
123    /// Current failure detail, if any.
124    pub error: Option<StepError>,
125}
126
127/// Cheap cloneable snapshot returned by [`TranscriptionJob::status`].
128#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
129pub struct TranscriptionStatus {
130    /// Overall job lifecycle state.
131    pub state: JobState,
132    /// Ordered pipeline operations, including one entry per planned chunk.
133    pub steps: Vec<StepStatus>,
134    /// Final canonical Markdown transcript, present only after completion.
135    pub transcript: Option<String>,
136}
137
138/// Cloneable handle for polling one in-memory transcription.
139#[derive(Clone, Debug)]
140pub struct TranscriptionJob {
141    status: Arc<RwLock<TranscriptionStatus>>,
142}
143
144impl TranscriptionJob {
145    /// Returns an in-memory snapshot without performing I/O or provider calls.
146    pub fn status(&self) -> TranscriptionStatus {
147        self.status
148            .read()
149            .unwrap_or_else(PoisonError::into_inner)
150            .clone()
151    }
152}
153
154/// Complete audio transcription pipeline backed by configured provider clients.
155#[derive(Clone, Debug)]
156pub struct AudioTranscriber {
157    gemini: Gemini,
158    codex: Codex,
159}
160
161pub(super) type PieceCache = Arc<dyn Fn(ChunkPlan) -> anyhow::Result<Option<String>> + Send + Sync>;
162pub(super) type PieceSink = Arc<dyn Fn(&ChunkTranscript) -> anyhow::Result<()> + Send + Sync>;
163
164impl AudioTranscriber {
165    /// Constructs a transcriber without receiving or resolving provider keys.
166    pub fn new(gemini: Gemini, codex: Codex) -> Self {
167        Self { gemini, codex }
168    }
169
170    /// Starts transcription of owned WAV bytes and immediately returns a job.
171    ///
172    /// The complete pipeline runs on the current Tokio runtime. If no runtime
173    /// is active, the returned job is immediately failed with a status error.
174    pub fn transcribe(&self, audio: Vec<u8>) -> TranscriptionJob {
175        self.start(audio, None, None)
176    }
177
178    pub(super) fn transcribe_durably(
179        &self,
180        audio: Vec<u8>,
181        cache: PieceCache,
182        sink: PieceSink,
183    ) -> TranscriptionJob {
184        self.start(audio, Some(cache), Some(sink))
185    }
186
187    fn start(
188        &self,
189        audio: Vec<u8>,
190        cache: Option<PieceCache>,
191        sink: Option<PieceSink>,
192    ) -> TranscriptionJob {
193        let status = Arc::new(RwLock::new(initial_status()));
194        let job = TranscriptionJob {
195            status: status.clone(),
196        };
197        let gemini = self.gemini.clone();
198        let codex = self.codex.clone();
199        match tokio::runtime::Handle::try_current() {
200            Ok(runtime) => {
201                runtime.spawn(run_job(audio, gemini, codex, status, cache, sink));
202            }
203            Err(_) => fail_job(
204                &status,
205                &Step::ValidateAudio,
206                Failure::new(
207                    "runtime_unavailable",
208                    "transcribe() requires an active Tokio runtime",
209                    true,
210                ),
211            ),
212        }
213        job
214    }
215}
216
217fn initial_status() -> TranscriptionStatus {
218    TranscriptionStatus {
219        state: JobState::Queued,
220        steps: vec![
221            pending(Step::ValidateAudio),
222            pending(Step::PlanChunks),
223            pending(Step::ReconcileTranscript),
224            pending(Step::SplitTranscript),
225        ],
226        transcript: None,
227    }
228}
229
230fn pending(step: Step) -> StepStatus {
231    StepStatus {
232        step,
233        state: StepState::Pending,
234        attempts: 0,
235        retry_after: None,
236        error: None,
237    }
238}
239
240#[derive(Clone, Debug)]
241struct Failure {
242    code: &'static str,
243    message: String,
244    retryable: bool,
245}
246
247impl Failure {
248    fn new(code: &'static str, message: impl Into<String>, retryable: bool) -> Self {
249        Self {
250            code,
251            message: concise(&message.into(), 2_000),
252            retryable,
253        }
254    }
255
256    fn step_error(&self) -> StepError {
257        StepError {
258            code: self.code.into(),
259            message: self.message.clone(),
260            retryable: self.retryable,
261        }
262    }
263}
264
265#[derive(Clone, Copy, Debug)]
266struct WavInfo {
267    duration_ms: u64,
268}
269
270#[derive(Clone, Copy, Debug)]
271pub(super) struct ChunkPlan {
272    pub(super) index: usize,
273    pub(super) total: usize,
274    pub(super) start_ms: u64,
275    pub(super) end_ms: u64,
276}
277
278#[derive(Clone, Debug)]
279pub(super) struct ChunkTranscript {
280    pub(super) plan: ChunkPlan,
281    pub(super) transcript: String,
282}
283
284async fn run_job(
285    audio: Vec<u8>,
286    gemini: Gemini,
287    codex: Codex,
288    status: Arc<RwLock<TranscriptionStatus>>,
289    cache: Option<PieceCache>,
290    sink: Option<PieceSink>,
291) {
292    set_job_running(&status);
293    set_step_running(&status, &Step::ValidateAudio, 1);
294    let validation_audio = audio.clone();
295    let info = match tokio::task::spawn_blocking(move || validate_wav(&validation_audio)).await {
296        Ok(Ok(info)) => info,
297        Ok(Err(error)) => {
298            fail_job(&status, &Step::ValidateAudio, error);
299            return;
300        }
301        Err(error) => {
302            fail_job(
303                &status,
304                &Step::ValidateAudio,
305                Failure::new(
306                    "validation_task_failed",
307                    format!("audio validation worker stopped: {error}"),
308                    true,
309                ),
310            );
311            return;
312        }
313    };
314    set_step_completed(&status, &Step::ValidateAudio);
315
316    set_step_running(&status, &Step::PlanChunks, 1);
317    let boundaries = chunk_boundaries(info.duration_ms);
318    if boundaries.is_empty() {
319        fail_job(
320            &status,
321            &Step::PlanChunks,
322            Failure::new("invalid_audio", "WAV contains no audio samples", false),
323        );
324        return;
325    }
326    let total = boundaries.len();
327    let plans = boundaries
328        .into_iter()
329        .enumerate()
330        .map(|(index, (start_ms, end_ms))| ChunkPlan {
331            index,
332            total,
333            start_ms,
334            end_ms,
335        })
336        .collect::<Vec<_>>();
337    install_chunk_steps(&status, total);
338    set_step_completed(&status, &Step::PlanChunks);
339
340    let mut chunks = vec![None; total];
341    let mut missing = Vec::with_capacity(total);
342    for plan in plans {
343        let cached = match cache.as_ref() {
344            Some(cache) => match cache(plan) {
345                Ok(value) => value,
346                Err(error) => {
347                    let step = Step::TranscribeChunk {
348                        index: plan.index,
349                        total: plan.total,
350                    };
351                    fail_job(
352                        &status,
353                        &step,
354                        Failure::new(
355                            "piece_cache_failed",
356                            format!(
357                                "reading cached transcript for chunk {} failed: {error:#}",
358                                plan.index
359                            ),
360                            true,
361                        ),
362                    );
363                    return;
364                }
365            },
366            None => None,
367        };
368        if let Some(transcript) = cached {
369            let step = Step::TranscribeChunk {
370                index: plan.index,
371                total: plan.total,
372            };
373            set_step_running(&status, &step, 1);
374            chunks[plan.index] = Some(ChunkTranscript { plan, transcript });
375            set_step_completed(&status, &step);
376        } else {
377            missing.push(plan);
378        }
379    }
380
381    let shared_audio = Arc::new(audio);
382    let mut work = stream::iter(missing.into_iter().map(|plan| {
383        let audio = shared_audio.clone();
384        let gemini = gemini.clone();
385        let status = status.clone();
386        let sink = sink.clone();
387        async move {
388            let result = transcribe_chunk(audio, gemini, status, plan, sink).await;
389            (plan.index, result)
390        }
391    }))
392    .buffer_unordered(MAX_CONCURRENT_CHUNKS);
393    let mut first_failure = None;
394    while let Some((index, result)) = work.next().await {
395        match result {
396            Ok(transcript) => chunks[index] = Some(transcript),
397            Err(error) if first_failure.is_none() => first_failure = Some(error),
398            Err(_) => {}
399        }
400    }
401    if first_failure.is_some() {
402        set_job_failed(&status);
403        return;
404    }
405    let ordered = chunks.into_iter().flatten().collect::<Vec<_>>();
406    if ordered.len() != total {
407        fail_job(
408            &status,
409            &Step::ReconcileTranscript,
410            Failure::new(
411                "chunk_result_missing",
412                "a completed chunk did not produce a transcript",
413                true,
414            ),
415        );
416        return;
417    }
418
419    let prompt = reconciliation_prompt(&ordered);
420    let transcript = match generate_with_retries(
421        &codex,
422        &status,
423        Step::ReconcileTranscript,
424        prompt,
425        "reconciliation_failed",
426    )
427    .await
428    {
429        Ok(value) => value,
430        Err(_) => return,
431    };
432    let mut pieces = transcript_pieces(&transcript);
433    if pieces.is_empty() {
434        fail_job(
435            &status,
436            &Step::ReconcileTranscript,
437            Failure::new(
438                "empty_transcript",
439                "Codex returned an empty reconciled transcript",
440                true,
441            ),
442        );
443        return;
444    }
445
446    let needs_second_pass = pieces
447        .iter()
448        .any(|piece| estimate_tokens(piece) > MAX_TRANSCRIPT_TOKENS);
449    if needs_second_pass {
450        let marked = match generate_with_retries(
451            &codex,
452            &status,
453            Step::SplitTranscript,
454            split_prompt(&transcript),
455            "split_failed",
456        )
457        .await
458        {
459            Ok(value) => value,
460            Err(_) => return,
461        };
462        pieces = transcript_pieces(&marked);
463        if pieces.is_empty()
464            || pieces
465                .iter()
466                .any(|piece| estimate_tokens(piece) > MAX_TRANSCRIPT_TOKENS)
467        {
468            fail_job(
469                &status,
470                &Step::SplitTranscript,
471                Failure::new(
472                    "split_invalid",
473                    "Codex did not place transcript boundaries below the size limit",
474                    true,
475                ),
476            );
477            return;
478        }
479    } else if pieces.len() > 1 {
480        set_step_running(&status, &Step::SplitTranscript, 1);
481        set_step_completed(&status, &Step::SplitTranscript);
482    } else {
483        set_step_skipped(&status, &Step::SplitTranscript);
484    }
485
486    let final_transcript = pieces.join("\n\n");
487    let mut snapshot = status.write().unwrap_or_else(PoisonError::into_inner);
488    snapshot.transcript = Some(final_transcript);
489    snapshot.state = JobState::Completed;
490}
491
492async fn transcribe_chunk(
493    audio: Arc<Vec<u8>>,
494    gemini: Gemini,
495    status: Arc<RwLock<TranscriptionStatus>>,
496    plan: ChunkPlan,
497    sink: Option<PieceSink>,
498) -> Result<ChunkTranscript, Failure> {
499    let step = Step::TranscribeChunk {
500        index: plan.index,
501        total: plan.total,
502    };
503    set_step_running(&status, &step, 1);
504    let prepared = tokio::task::spawn_blocking(move || {
505        wav_interval_to_opus(&audio, plan.start_ms, plan.end_ms)
506    })
507    .await
508    .map_err(|error| {
509        Failure::new(
510            "audio_task_failed",
511            format!("chunk {} audio worker stopped: {error}", plan.index),
512            true,
513        )
514    })?
515    .map_err(|error| {
516        Failure::new(
517            "audio_preparation_failed",
518            format!("chunk {} could not be prepared: {error:#}", plan.index),
519            false,
520        )
521    });
522    let opus = match prepared {
523        Ok(value) => value,
524        Err(error) => {
525            fail_step(&status, &step, &error);
526            return Err(error);
527        }
528    };
529
530    for attempt in 1..=MAX_PROVIDER_ATTEMPTS {
531        set_step_running(&status, &step, attempt);
532        let result = request_chunk_transcript(&gemini, &opus, plan).await;
533        match result {
534            Ok(transcript) => {
535                let completed = ChunkTranscript { plan, transcript };
536                if let Some(sink) = sink.as_ref()
537                    && let Err(error) = sink(&completed)
538                {
539                    let failure = Failure::new(
540                        "piece_persistence_failed",
541                        format!(
542                            "persisting transcript for chunk {} failed: {error:#}",
543                            plan.index
544                        ),
545                        true,
546                    );
547                    fail_step(&status, &step, &failure);
548                    return Err(failure);
549                }
550                set_step_completed(&status, &step);
551                return Ok(completed);
552            }
553            Err(error) if error.retryable && attempt < MAX_PROVIDER_ATTEMPTS => {
554                let delay = retry_delay(attempt);
555                set_step_retrying(&status, &step, attempt, delay, &error);
556                tokio::time::sleep(delay).await;
557            }
558            Err(error) => {
559                fail_step(&status, &step, &error);
560                return Err(error);
561            }
562        }
563    }
564    unreachable!("provider attempt loop always returns")
565}
566
567async fn request_chunk_transcript(
568    gemini: &Gemini,
569    opus: &[u8],
570    plan: ChunkPlan,
571) -> Result<String, Failure> {
572    let media = MediaInput::audio("audio/ogg", opus.to_vec()).map_err(|error| {
573        Failure::new(
574            "audio_preparation_failed",
575            format!("preparing inline Ogg Opus audio: {error}"),
576            false,
577        )
578    })?;
579    let mut request = MultimodalRequest::new(transcription_prompt(plan), vec![media]);
580    request.options = GenerationOptions {
581        max_output_tokens: Some(32_768),
582        temperature: None,
583        thinking_level: Some(ThinkingLevel::High),
584        service_tier: ServiceTier::Standard,
585    };
586    request.structured_output = Some(StructuredOutput::new(transcription_schema()).map_err(
587        |error| {
588            Failure::new(
589                "schema_invalid",
590                format!("validating transcription output schema: {error}"),
591                false,
592            )
593        },
594    )?);
595    let response = gemini
596        .infer_pro_multimodal(request)
597        .await
598        .map_err(|error| {
599            let retryable = gemini_retryable(&error);
600            Failure::new(
601                "gemini_failed",
602                format!("Gemini chunk transcription failed: {error}"),
603                retryable,
604            )
605        })?;
606    if response.status != CompletionStatus::Completed {
607        return Err(Failure::new(
608            "gemini_incomplete",
609            "Gemini chunk transcription did not complete",
610            true,
611        ));
612    }
613    let text = response.text.ok_or_else(|| {
614        Failure::new(
615            "gemini_response_invalid",
616            "Gemini returned no structured transcript text",
617            true,
618        )
619    })?;
620    let transcript: Value = serde_json::from_str(&text).map_err(|error| {
621        Failure::new(
622            "gemini_response_invalid",
623            format!("Gemini returned invalid structured transcript JSON: {error}"),
624            true,
625        )
626    })?;
627    if transcript
628        .get("utterances")
629        .and_then(Value::as_array)
630        .is_none()
631    {
632        return Err(Failure::new(
633            "gemini_response_invalid",
634            "Gemini transcript omitted the utterances array",
635            true,
636        ));
637    }
638    serde_json::to_string_pretty(&transcript).map_err(|error| {
639        Failure::new(
640            "gemini_response_invalid",
641            format!("serializing Gemini transcript: {error}"),
642            true,
643        )
644    })
645}
646
647async fn generate_with_retries(
648    codex: &Codex,
649    status: &Arc<RwLock<TranscriptionStatus>>,
650    step: Step,
651    prompt: String,
652    code: &'static str,
653) -> Result<String, Failure> {
654    for attempt in 1..=MAX_PROVIDER_ATTEMPTS {
655        set_step_running(status, &step, attempt);
656        let mut request = GenerationRequest::new(prompt.clone(), RECONCILIATION_MODEL);
657        request.reasoning_effort = ReasoningEffort::XHigh;
658        request.ephemeral = true;
659        request.timeout = Duration::from_secs(30 * 60);
660        match codex.generate(request).await {
661            Ok(response) if !response.answer.trim().is_empty() => {
662                set_step_completed(status, &step);
663                return Ok(response.answer);
664            }
665            Ok(_) if attempt < MAX_PROVIDER_ATTEMPTS => {
666                let error = Failure::new(code, "Codex returned empty transcript text", true);
667                let delay = retry_delay(attempt);
668                set_step_retrying(status, &step, attempt, delay, &error);
669                tokio::time::sleep(delay).await;
670            }
671            Ok(_) => {
672                let error = Failure::new(code, "Codex returned empty transcript text", true);
673                fail_job(status, &step, error.clone());
674                return Err(error);
675            }
676            Err(provider)
677                if codex_retryable(provider.kind()) && attempt < MAX_PROVIDER_ATTEMPTS =>
678            {
679                let error = Failure::new(
680                    code,
681                    format!("Codex transcript processing failed: {provider}"),
682                    true,
683                );
684                let delay = retry_delay(attempt);
685                set_step_retrying(status, &step, attempt, delay, &error);
686                tokio::time::sleep(delay).await;
687            }
688            Err(provider) => {
689                let retryable = codex_retryable(provider.kind());
690                let error = Failure::new(
691                    code,
692                    format!("Codex transcript processing failed: {provider}"),
693                    retryable,
694                );
695                fail_job(status, &step, error.clone());
696                return Err(error);
697            }
698        }
699    }
700    unreachable!("provider attempt loop always returns")
701}
702
703fn retry_delay(attempt: u32) -> Duration {
704    Duration::from_secs(60 * (1_u64 << attempt.saturating_sub(1).min(5)))
705}
706
707fn gemini_retryable(error: &GeminiError) -> bool {
708    match error {
709        GeminiError::Transport(_)
710        | GeminiError::Protocol(_)
711        | GeminiError::SpendingLimitReached { .. } => true,
712        GeminiError::Provider { status, .. } => {
713            matches!(*status, 408 | 409 | 425 | 429) || *status >= 500
714        }
715        GeminiError::InvalidApiKey | GeminiError::InvalidInput(_) | GeminiError::Accounting(_) => {
716            false
717        }
718    }
719}
720
721fn codex_retryable(kind: CodexErrorKind) -> bool {
722    matches!(
723        kind,
724        CodexErrorKind::Unavailable
725            | CodexErrorKind::RateLimited
726            | CodexErrorKind::Capacity
727            | CodexErrorKind::Timeout
728            | CodexErrorKind::EmptyOutput
729            | CodexErrorKind::Protocol
730    )
731}
732
733fn validate_wav(audio: &[u8]) -> Result<WavInfo, Failure> {
734    if audio.is_empty() {
735        return Err(Failure::new(
736            "invalid_audio",
737            "audio byte buffer is empty",
738            false,
739        ));
740    }
741    let reader = WavReader::new(Cursor::new(audio)).map_err(|error| {
742        Failure::new(
743            "invalid_audio",
744            format!("invalid WAV recording: {error}"),
745            false,
746        )
747    })?;
748    let spec = reader.spec();
749    if spec.sample_rate == 0 || !(1..=OPUS_MAX_CHANNELS as u16).contains(&spec.channels) {
750        return Err(Failure::new(
751            "invalid_audio",
752            "WAV must have a positive sample rate and one or two channels",
753            false,
754        ));
755    }
756    let supported = matches!(
757        (spec.sample_format, spec.bits_per_sample),
758        (SampleFormat::Float, 32) | (SampleFormat::Int, 1..=32)
759    );
760    if !supported {
761        return Err(Failure::new(
762            "invalid_audio",
763            format!(
764                "unsupported WAV sample format: {:?} with {} bits",
765                spec.sample_format, spec.bits_per_sample
766            ),
767            false,
768        ));
769    }
770    let declared_audio_bytes = u64::from(reader.duration())
771        .saturating_mul(u64::from(spec.channels))
772        .saturating_mul(u64::from(spec.bits_per_sample).div_ceil(8));
773    if declared_audio_bytes > audio.len() as u64 {
774        return Err(Failure::new(
775            "invalid_audio",
776            format!(
777                "invalid WAV recording: header declares {declared_audio_bytes} audio bytes but the buffer has only {} bytes",
778                audio.len()
779            ),
780            false,
781        ));
782    }
783    let duration_ms = (u64::from(reader.duration()) * 1_000).div_ceil(u64::from(spec.sample_rate));
784    if duration_ms == 0 {
785        return Err(Failure::new(
786            "invalid_audio",
787            "WAV contains no audio samples",
788            false,
789        ));
790    }
791    Ok(WavInfo { duration_ms })
792}
793
794fn chunk_boundaries(duration_ms: u64) -> Vec<(u64, u64)> {
795    if duration_ms == 0 {
796        return Vec::new();
797    }
798    if duration_ms <= MAX_CHUNK_MILLISECONDS {
799        return vec![(0, duration_ms)];
800    }
801    let advance = MAX_CHUNK_MILLISECONDS - CHUNK_OVERLAP_MILLISECONDS;
802    let chunks = (duration_ms - CHUNK_OVERLAP_MILLISECONDS).div_ceil(advance);
803    let window = (duration_ms + (chunks - 1) * CHUNK_OVERLAP_MILLISECONDS).div_ceil(chunks);
804    let step = window - CHUNK_OVERLAP_MILLISECONDS;
805    (0..chunks)
806        .map(|index| {
807            let start = index * step;
808            (start, (start + window).min(duration_ms))
809        })
810        .filter(|(start, end)| end > start)
811        .collect()
812}
813
814fn wav_interval_to_opus(audio: &[u8], start_ms: u64, end_ms: u64) -> anyhow::Result<Vec<u8>> {
815    let mut reader = WavReader::new(Cursor::new(audio)).context("opening in-memory WAV audio")?;
816    let spec = reader.spec();
817    ensure!(end_ms > start_ms, "audio interval is empty");
818    let start_frame = u32::try_from(start_ms * u64::from(spec.sample_rate) / 1_000)
819        .context("audio interval starts beyond WAV limits")?;
820    let end_frame = u32::try_from(end_ms * u64::from(spec.sample_rate) / 1_000)
821        .context("audio interval ends beyond WAV limits")?
822        .min(reader.duration());
823    let sample_values = usize::try_from(
824        u64::from(end_frame.saturating_sub(start_frame)) * u64::from(spec.channels),
825    )
826    .context("audio interval is too large for this platform")?;
827    reader.seek(start_frame).context("seeking WAV interval")?;
828    let samples = match (spec.sample_format, spec.bits_per_sample) {
829        (SampleFormat::Float, 32) => reader
830            .samples::<f32>()
831            .take(sample_values)
832            .map(|sample| sample.context("reading 32-bit float WAV sample"))
833            .collect::<anyhow::Result<Vec<_>>>()?,
834        (SampleFormat::Int, 1..=8) => {
835            let scale = 2.0_f32.powi(i32::from(spec.bits_per_sample) - 1);
836            reader
837                .samples::<i8>()
838                .take(sample_values)
839                .map(|sample| {
840                    sample
841                        .map(|value| f32::from(value) / scale)
842                        .context("reading 8-bit WAV sample")
843                })
844                .collect::<anyhow::Result<Vec<_>>>()?
845        }
846        (SampleFormat::Int, 9..=16) => {
847            let scale = 2.0_f32.powi(i32::from(spec.bits_per_sample) - 1);
848            reader
849                .samples::<i16>()
850                .take(sample_values)
851                .map(|sample| {
852                    sample
853                        .map(|value| f32::from(value) / scale)
854                        .context("reading 16-bit WAV sample")
855                })
856                .collect::<anyhow::Result<Vec<_>>>()?
857        }
858        (SampleFormat::Int, 17..=32) => {
859            let scale = 2.0_f64.powi(i32::from(spec.bits_per_sample) - 1) as f32;
860            reader
861                .samples::<i32>()
862                .take(sample_values)
863                .map(|sample| {
864                    sample
865                        .map(|value| value as f32 / scale)
866                        .context("reading high-resolution integer WAV sample")
867                })
868                .collect::<anyhow::Result<Vec<_>>>()?
869        }
870        _ => anyhow::bail!(
871            "unsupported WAV sample format: {:?} with {} bits",
872            spec.sample_format,
873            spec.bits_per_sample
874        ),
875    };
876    ensure!(
877        samples.len() == sample_values,
878        "WAV audio ended before the planned interval"
879    );
880    let channels = usize::from(spec.channels);
881    ensure!(
882        samples.len().is_multiple_of(channels),
883        "WAV audio ended with an incomplete frame"
884    );
885    ensure!(
886        !samples.is_empty(),
887        "WAV audio interval contains no samples"
888    );
889    ensure!(
890        samples.iter().all(|sample| sample.is_finite()),
891        "WAV audio contains a non-finite sample"
892    );
893    let pcm = samples
894        .into_iter()
895        .map(|sample| sample.clamp(-1.0, 1.0))
896        .collect::<Vec<_>>();
897    let pcm = resample_interleaved(&pcm, spec.sample_rate, channels)?;
898    let bitrate = OPUS_BITRATE_PER_CHANNEL_BPS * u32::from(spec.channels);
899    Ok(encode_ogg_opus(&pcm, channels, bitrate))
900}
901
902fn resample_interleaved(
903    source: &[f32],
904    source_rate: u32,
905    channels: usize,
906) -> anyhow::Result<Vec<f32>> {
907    ensure!(source_rate > 0, "WAV sample rate must be positive");
908    ensure!(
909        (1..=OPUS_MAX_CHANNELS).contains(&channels),
910        "Ogg Opus encoding supports mono or stereo PCM"
911    );
912    ensure!(
913        source.len().is_multiple_of(channels),
914        "PCM ended with an incomplete frame"
915    );
916    ensure!(!source.is_empty(), "PCM contains no samples");
917    if source_rate == OPUS_SAMPLE_RATE {
918        return Ok(source.to_vec());
919    }
920    let source_frames = source.len() / channels;
921    let output_frames = usize::try_from(
922        (source_frames as u128 * u128::from(OPUS_SAMPLE_RATE)).div_ceil(u128::from(source_rate)),
923    )
924    .context("resampled audio is too large for this platform")?;
925    let output_samples = output_frames
926        .checked_mul(channels)
927        .context("resampled audio is too large for this platform")?;
928    let mut output = Vec::with_capacity(output_samples);
929    for output_frame in 0..output_frames {
930        let source_position = output_frame as u128 * u128::from(source_rate);
931        let lower = usize::try_from(source_position / u128::from(OPUS_SAMPLE_RATE))
932            .context("resampling position is too large for this platform")?
933            .min(source_frames - 1);
934        let upper = (lower + 1).min(source_frames - 1);
935        let fraction =
936            (source_position % u128::from(OPUS_SAMPLE_RATE)) as f32 / OPUS_SAMPLE_RATE as f32;
937        for channel in 0..channels {
938            let lower_sample = source[lower * channels + channel];
939            let upper_sample = source[upper * channels + channel];
940            output.push(lower_sample + (upper_sample - lower_sample) * fraction);
941        }
942    }
943    Ok(output)
944}
945
946fn transcription_prompt(plan: ChunkPlan) -> String {
947    format!(
948        "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.",
949        plan.index + 1,
950        plan.total,
951        plan.start_ms as f64 / 1_000.0,
952        plan.end_ms as f64 / 1_000.0,
953    )
954}
955
956fn transcription_schema() -> Value {
957    json!({
958        "type":"object",
959        "properties":{
960            "utterances":{
961                "type":"array",
962                "items":{
963                    "type":"object",
964                    "properties":{
965                        "start_seconds":{"type":"number"},
966                        "end_seconds":{"type":"number"},
967                        "speaker":{"type":"string"},
968                        "language":{"type":"string"},
969                        "original_text":{"type":"string"},
970                        "english_translation":{"type":"string"},
971                        "annotations":{"type":"array","items":{"type":"string"}},
972                        "confidence":{"type":"string","enum":["high","medium","low"]}
973                    },
974                    "required":["start_seconds","end_seconds","speaker","language","original_text","english_translation","annotations","confidence"]
975                }
976            },
977            "chunk_notes":{"type":"array","items":{"type":"string"}}
978        },
979        "required":["utterances","chunk_notes"]
980    })
981}
982
983fn reconciliation_prompt(chunks: &[ChunkTranscript]) -> String {
984    let mut prompt = format!(
985        "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"
986    );
987    for chunk in chunks {
988        prompt.push_str(&format!(
989            "\n\nCHUNK {:05} | source offsets {:.3}–{:.3} seconds\n{}",
990            chunk.plan.index,
991            chunk.plan.start_ms as f64 / 1_000.0,
992            chunk.plan.end_ms as f64 / 1_000.0,
993            chunk.transcript,
994        ));
995    }
996    prompt
997}
998
999fn split_prompt(transcript: &str) -> String {
1000    format!(
1001        "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}"
1002    )
1003}
1004
1005fn transcript_pieces(transcript: &str) -> Vec<String> {
1006    transcript
1007        .split(TRANSCRIPT_BREAK)
1008        .map(str::trim)
1009        .filter(|piece| !piece.is_empty())
1010        .map(str::to_owned)
1011        .collect()
1012}
1013
1014fn estimate_tokens(value: &str) -> u64 {
1015    (value.chars().count() as u64).div_ceil(ESTIMATED_CHARACTERS_PER_TOKEN)
1016}
1017
1018fn set_job_running(status: &Arc<RwLock<TranscriptionStatus>>) {
1019    status.write().unwrap_or_else(PoisonError::into_inner).state = JobState::Running;
1020}
1021
1022fn set_job_failed(status: &Arc<RwLock<TranscriptionStatus>>) {
1023    status.write().unwrap_or_else(PoisonError::into_inner).state = JobState::Failed;
1024}
1025
1026fn install_chunk_steps(status: &Arc<RwLock<TranscriptionStatus>>, total: usize) {
1027    let mut snapshot = status.write().unwrap_or_else(PoisonError::into_inner);
1028    let insertion = snapshot
1029        .steps
1030        .iter()
1031        .position(|entry| entry.step == Step::ReconcileTranscript)
1032        .expect("initial status contains reconciliation");
1033    snapshot.steps.splice(
1034        insertion..insertion,
1035        (0..total).map(|index| pending(Step::TranscribeChunk { index, total })),
1036    );
1037}
1038
1039fn mutate_step(
1040    status: &Arc<RwLock<TranscriptionStatus>>,
1041    step: &Step,
1042    change: impl FnOnce(&mut StepStatus),
1043) {
1044    let mut snapshot = status.write().unwrap_or_else(PoisonError::into_inner);
1045    if let Some(entry) = snapshot.steps.iter_mut().find(|entry| &entry.step == step) {
1046        change(entry);
1047    }
1048}
1049
1050fn set_step_running(status: &Arc<RwLock<TranscriptionStatus>>, step: &Step, attempt: u32) {
1051    mutate_step(status, step, |entry| {
1052        entry.state = StepState::Running;
1053        entry.attempts = attempt;
1054        entry.retry_after = None;
1055        entry.error = None;
1056    });
1057}
1058
1059fn set_step_retrying(
1060    status: &Arc<RwLock<TranscriptionStatus>>,
1061    step: &Step,
1062    attempt: u32,
1063    delay: Duration,
1064    error: &Failure,
1065) {
1066    mutate_step(status, step, |entry| {
1067        entry.state = StepState::Retrying;
1068        entry.attempts = attempt;
1069        entry.retry_after = Some(delay);
1070        entry.error = Some(error.step_error());
1071    });
1072}
1073
1074fn set_step_completed(status: &Arc<RwLock<TranscriptionStatus>>, step: &Step) {
1075    mutate_step(status, step, |entry| {
1076        entry.state = StepState::Completed;
1077        entry.retry_after = None;
1078        entry.error = None;
1079    });
1080}
1081
1082fn set_step_skipped(status: &Arc<RwLock<TranscriptionStatus>>, step: &Step) {
1083    mutate_step(status, step, |entry| {
1084        entry.state = StepState::Skipped;
1085        entry.retry_after = None;
1086        entry.error = None;
1087    });
1088}
1089
1090fn fail_step(status: &Arc<RwLock<TranscriptionStatus>>, step: &Step, error: &Failure) {
1091    mutate_step(status, step, |entry| {
1092        entry.state = StepState::Failed;
1093        entry.retry_after = None;
1094        entry.error = Some(error.step_error());
1095    });
1096}
1097
1098fn fail_job(status: &Arc<RwLock<TranscriptionStatus>>, step: &Step, error: Failure) {
1099    fail_step(status, step, &error);
1100    set_job_failed(status);
1101}
1102
1103fn concise(value: &str, limit: usize) -> String {
1104    let clean = value.split_whitespace().collect::<Vec<_>>().join(" ");
1105    clean.chars().take(limit).collect()
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111    use hound::{WavSpec, WavWriter};
1112
1113    fn wav_bytes(channels: u16, sample_rate: u32, frames: u32) -> Vec<u8> {
1114        let mut cursor = Cursor::new(Vec::new());
1115        {
1116            let mut writer = WavWriter::new(
1117                &mut cursor,
1118                WavSpec {
1119                    channels,
1120                    sample_rate,
1121                    bits_per_sample: 16,
1122                    sample_format: SampleFormat::Int,
1123                },
1124            )
1125            .unwrap();
1126            for frame in 0..frames {
1127                let phase = frame as f32 * 440.0 * std::f32::consts::TAU / sample_rate as f32;
1128                for _ in 0..channels {
1129                    writer.write_sample((phase.sin() * 8_192.0) as i16).unwrap();
1130                }
1131            }
1132            writer.finalize().unwrap();
1133        }
1134        cursor.into_inner()
1135    }
1136
1137    #[test]
1138    fn long_recordings_are_equalized_with_overlap() {
1139        let chunks = chunk_boundaries(8 * 60 * 1_000);
1140        assert_eq!(chunks.len(), 3);
1141        assert!(
1142            chunks
1143                .iter()
1144                .all(|(start, end)| end - start <= MAX_CHUNK_MILLISECONDS)
1145        );
1146        assert_eq!(chunks[0].1 - chunks[1].0, CHUNK_OVERLAP_MILLISECONDS);
1147        assert_eq!(chunks[1].1 - chunks[2].0, CHUNK_OVERLAP_MILLISECONDS);
1148        assert!((chunks[0].1 - chunks[0].0).abs_diff(chunks[2].1 - chunks[2].0) <= 1);
1149    }
1150
1151    #[test]
1152    fn interval_encoding_is_entirely_in_memory() {
1153        let wav = wav_bytes(2, 44_100, 4_410);
1154        validate_wav(&wav).unwrap();
1155        let opus = wav_interval_to_opus(&wav, 0, 100).unwrap();
1156        assert_eq!(&opus[..4], b"OggS");
1157        let (decoded, head) = ruopus::decode_ogg_opus(&opus).unwrap();
1158        assert_eq!(head.channel_count, 2);
1159        assert_eq!(head.input_sample_rate, OPUS_SAMPLE_RATE);
1160        assert!(!decoded.is_empty());
1161    }
1162
1163    #[test]
1164    fn duration_rounds_up_to_cover_the_final_sample() {
1165        let wav = wav_bytes(1, 48_000, 49);
1166        assert_eq!(validate_wav(&wav).unwrap().duration_ms, 2);
1167    }
1168
1169    #[test]
1170    fn initial_status_is_serializable_and_queued() {
1171        let status = initial_status();
1172        assert_eq!(status.state, JobState::Queued);
1173        assert_eq!(status.steps.len(), 4);
1174        let serialized = serde_json::to_string(&status).unwrap();
1175        let restored: TranscriptionStatus = serde_json::from_str(&serialized).unwrap();
1176        assert_eq!(restored, status);
1177    }
1178
1179    #[test]
1180    fn invalid_wav_is_nonretryable() {
1181        let error = validate_wav(b"not a wav").unwrap_err();
1182        assert_eq!(error.code, "invalid_audio");
1183        assert!(!error.retryable);
1184    }
1185
1186    #[test]
1187    fn transcript_breaks_are_removed_from_public_output() {
1188        let pieces = transcript_pieces(&format!("first\n{TRANSCRIPT_BREAK}\nsecond"));
1189        assert_eq!(pieces, vec!["first", "second"]);
1190    }
1191}