Skip to main content

kcode_audio_transcribe/
lib.rs

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