1#![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
28pub const TRANSCRIPTION_MODEL: &str = kcode_gemini_api::GEMINI_31_PRO;
30pub const RECONCILIATION_MODEL: &str = "gpt-5.6-sol";
32pub 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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
50#[serde(rename_all = "snake_case")]
51pub enum JobState {
52 Queued,
54 Running,
56 Completed,
58 Failed,
60}
61
62#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
64#[serde(tag = "kind", rename_all = "snake_case")]
65pub enum Step {
66 ValidateAudio,
68 PlanChunks,
70 TranscribeChunk {
72 index: usize,
74 total: usize,
76 },
77 ReconcileTranscript,
79 SplitTranscript,
81}
82
83#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
85#[serde(rename_all = "snake_case")]
86pub enum StepState {
87 Pending,
89 Running,
91 Retrying,
93 Completed,
95 Skipped,
97 Failed,
99}
100
101#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
103pub struct StepError {
104 pub code: String,
106 pub message: String,
108 pub retryable: bool,
110}
111
112#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
114pub struct StepStatus {
115 pub step: Step,
117 pub state: StepState,
119 pub attempts: u32,
121 pub retry_after: Option<Duration>,
123 pub error: Option<StepError>,
125}
126
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
129pub struct TranscriptionStatus {
130 pub state: JobState,
132 pub steps: Vec<StepStatus>,
134 pub transcript: Option<String>,
136}
137
138#[derive(Clone, Debug)]
140pub struct TranscriptionJob {
141 status: Arc<RwLock<TranscriptionStatus>>,
142}
143
144impl TranscriptionJob {
145 pub fn status(&self) -> TranscriptionStatus {
147 self.status
148 .read()
149 .unwrap_or_else(PoisonError::into_inner)
150 .clone()
151 }
152}
153
154#[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 pub fn new(gemini: Gemini, codex: Codex) -> Self {
167 Self { gemini, codex }
168 }
169
170 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}