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
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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
48#[serde(rename_all = "snake_case")]
49pub enum JobState {
50 Queued,
52 Running,
54 Completed,
56 Failed,
58}
59
60#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
62#[serde(tag = "kind", rename_all = "snake_case")]
63pub enum Step {
64 ValidateAudio,
66 PlanChunks,
68 TranscribeChunk {
70 index: usize,
72 total: usize,
74 },
75 ReconcileTranscript,
77 SplitTranscript,
79}
80
81#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum StepState {
85 Pending,
87 Running,
89 Retrying,
91 Completed,
93 Skipped,
95 Failed,
97}
98
99#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
101pub struct StepError {
102 pub code: String,
104 pub message: String,
106 pub retryable: bool,
108}
109
110#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
112pub struct StepStatus {
113 pub step: Step,
115 pub state: StepState,
117 pub attempts: u32,
119 pub retry_after: Option<Duration>,
121 pub error: Option<StepError>,
123}
124
125#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
127pub struct TranscriptionStatus {
128 pub state: JobState,
130 pub steps: Vec<StepStatus>,
132 pub transcript: Option<String>,
134}
135
136#[derive(Clone, Debug)]
138pub struct TranscriptionJob {
139 status: Arc<RwLock<TranscriptionStatus>>,
140}
141
142impl TranscriptionJob {
143 pub fn status(&self) -> TranscriptionStatus {
145 self.status
146 .read()
147 .unwrap_or_else(PoisonError::into_inner)
148 .clone()
149 }
150}
151
152#[derive(Clone, Debug)]
154pub struct AudioTranscriber {
155 gemini: Gemini,
156 codex: Codex,
157}
158
159impl AudioTranscriber {
160 pub fn new(gemini: Gemini, codex: Codex) -> Self {
162 Self { gemini, codex }
163 }
164
165 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}