1#![deny(missing_docs)]
8#![forbid(unsafe_code)]
9
10use std::{
11 collections::HashMap,
12 fs,
13 path::{Path, PathBuf},
14 sync::{Arc, Mutex, Weak},
15 time::Duration,
16};
17
18use anyhow::{Context, ensure};
19use chrono::{DateTime, Duration as ChronoDuration, Utc};
20use kcode_speech_classification::SpeechClassifier;
21use rusqlite::{Connection, OptionalExtension, params};
22use serde::Serialize;
23use sha2::{Digest, Sha256};
24use tokio::io::AsyncWriteExt;
25use uuid::Uuid;
26
27pub use identity::{
28 CLASSIFIER_MODEL, CLASSIFIER_PROMPT_VERSION, CLASSIFIER_PROVIDER, CLASSIFIER_SCHEMA_VERSION,
29 CandidateMapping, Cefr, ConfirmationState, CorrectionChunk, CorrectionObservation,
30 CorrectionPacket, FeatureRow, ObservationConfirmation, ObservationKey, ParsedChunk,
31 ParsedSpeaker, ParsedUtterance, RecordingConfirmation,
32};
33use identity::{
34 ClassificationContext, apply_confirmations, classify_speakers, parse_and_validate_chunk,
35 restore_packet_training,
36};
37pub use transcribe::{
38 AudioChunkCall, AudioChunkRequest, AudioTranscriber, GEMINI_SPEAKER_PROMPT_V0_1,
39 IntelligenceError, IntelligenceFuture, JobState, RECONCILIATION_MODEL,
40 RECONCILIATION_REASONING, Step, StepError, StepState, StepStatus, TRANSCRIPTION_MODEL,
41 TextGenerationCall, TextGenerationRequest, TranscriptionJob, TranscriptionStatus,
42};
43use transcribe::{ChunkPlan, ChunkTranscript, PIECE_CACHE_REVISION, PieceCache, PieceSink};
44
45mod identity;
46mod transcribe;
47
48const INITIAL_MIGRATION: &str = include_str!("../migrations/001_initial.sql");
49const RELEASE_DEFERRED_INGRESS_MIGRATION: &str =
50 include_str!("../migrations/002_release_deferred_ingress.sql");
51const TRANSCRIPTION_STATUS_MIGRATION: &str =
52 include_str!("../migrations/003_transcription_status.sql");
53const RETRY_ROUNDED_WAV_INTERVALS_MIGRATION: &str =
54 include_str!("../migrations/004_retry_rounded_wav_intervals.sql");
55const UNIFIED_INGRESS_QUEUE_MIGRATION: &str =
56 include_str!("../migrations/005_unified_ingress_queue.sql");
57const STANDALONE_LIBRARY_MIGRATION: &str = include_str!("../migrations/006_standalone_library.sql");
58const DURABLE_TRANSCRIPT_PIECES_MIGRATION: &str =
59 include_str!("../migrations/007_durable_transcript_pieces.sql");
60const UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION: &str =
61 include_str!("../migrations/008_unique_transcription_attempts.sql");
62const USAGE_USER_MIGRATION: &str = include_str!("../migrations/009_usage_user.sql");
63const SPEAKER_CORRECTION_PACKETS_MIGRATION: &str =
64 include_str!("../migrations/010_speaker_correction_packets.sql");
65
66const DATABASE_FILENAME: &str = "state.sqlite3";
67const ORIGINALS_DIRECTORY: &str = "originals";
68const FAILURE_LIMIT: i64 = 5;
69const RETRY_DELAY_SECONDS: i64 = 15;
70const LATEST_SCHEMA_VERSION: i64 = 10;
71
72const FRESH_SCHEMA: &str = r#"
73CREATE TABLE audio_recordings (
74 id TEXT PRIMARY KEY NOT NULL,
75 user_id TEXT NOT NULL CHECK(length(user_id) > 0),
76 sha256 TEXT NOT NULL UNIQUE CHECK(length(sha256) = 64),
77 original_filename TEXT NOT NULL,
78 content_type TEXT NOT NULL,
79 size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0),
80 source_created_at TEXT NOT NULL,
81 received_at TEXT NOT NULL,
82 updated_at TEXT NOT NULL,
83 original_relative_path TEXT NOT NULL,
84 status TEXT NOT NULL CHECK(status IN (
85 'uploaded', 'chunking', 'transcribing', 'reconciling',
86 'ready_for_ingress', 'ingressing', 'ingress_failed', 'complete', 'failed'
87 )),
88 gemini_model TEXT NOT NULL,
89 reconciliation_model TEXT NOT NULL,
90 reconciliation_reasoning TEXT NOT NULL,
91 final_transcript TEXT,
92 attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count >= 0),
93 next_attempt_at TEXT,
94 last_error TEXT,
95 transcription_status_json TEXT,
96 failure_retryable INTEGER NOT NULL DEFAULT 1
97 CHECK(failure_retryable IN (0, 1)),
98 correction_packet_json TEXT
99);
100
101CREATE INDEX audio_recordings_work_queue
102ON audio_recordings(status, next_attempt_at, received_at);
103
104CREATE TABLE audio_transcript_pieces (
105 recording_id TEXT NOT NULL REFERENCES audio_recordings(id) ON DELETE CASCADE,
106 attempt_id TEXT NOT NULL CHECK(length(attempt_id) > 0),
107 cache_revision TEXT NOT NULL CHECK(length(cache_revision) > 0),
108 piece_index INTEGER NOT NULL CHECK(piece_index >= 0),
109 piece_count INTEGER NOT NULL CHECK(piece_count > 0 AND piece_index < piece_count),
110 audio_start_ms INTEGER NOT NULL CHECK(audio_start_ms >= 0),
111 audio_end_ms INTEGER NOT NULL CHECK(audio_end_ms > audio_start_ms),
112 transcript_json TEXT NOT NULL,
113 raw_gemini_response TEXT NOT NULL,
114 parsed_json TEXT NOT NULL,
115 created_at TEXT NOT NULL,
116 PRIMARY KEY(recording_id, attempt_id, piece_index)
117);
118
119CREATE INDEX audio_transcript_pieces_cache_lookup
120ON audio_transcript_pieces(
121 recording_id,
122 cache_revision,
123 piece_index,
124 piece_count,
125 audio_start_ms,
126 audio_end_ms,
127 created_at
128);
129
130PRAGMA user_version = 10;
131"#;
132
133#[derive(Clone, Debug)]
135pub struct AudioInput {
136 pub user_id: String,
138 pub bytes: Vec<u8>,
140 pub recorded_at: DateTime<Utc>,
142 pub original_filename: Option<String>,
144}
145
146#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
148pub struct Submission {
149 pub recording_id: Uuid,
151 pub deduplicated: bool,
153}
154
155#[derive(Clone, Debug, Serialize)]
157pub struct Status {
158 pub recordings: Vec<RecordingStatus>,
160}
161
162#[derive(Clone, Debug, Serialize)]
164pub struct RecordingStatus {
165 pub id: Uuid,
167 pub user_id: String,
169 pub sha256: String,
171 pub original_filename: String,
173 pub size_bytes: u64,
175 pub recorded_at: DateTime<Utc>,
177 pub received_at: DateTime<Utc>,
179 pub transcription_model: String,
181 pub reconciliation_model: String,
183 pub reconciliation_reasoning: String,
185 pub state: RecordingState,
187 pub correction_packet: Option<CorrectionPacket>,
189}
190
191#[derive(Clone, Debug, Serialize)]
193#[serde(tag = "kind", rename_all = "snake_case")]
194pub enum RecordingState {
195 Queued,
197 Processing {
199 attempt: u8,
201 progress: TranscriptionStatus,
203 },
204 Complete {
206 transcript: String,
208 },
209 Failed {
211 attempts: u8,
213 error: String,
215 retryable: bool,
217 },
218}
219
220#[derive(Clone, Copy, Debug, Eq, PartialEq)]
222pub enum ErrorKind {
223 InvalidInput,
225 NotFound,
227 Conflict,
229 Internal,
231}
232
233#[derive(Debug)]
235pub struct Error {
236 kind: ErrorKind,
237 message: String,
238}
239
240impl Error {
241 fn invalid(message: impl Into<String>) -> Self {
242 Self {
243 kind: ErrorKind::InvalidInput,
244 message: message.into(),
245 }
246 }
247
248 fn not_found() -> Self {
249 Self {
250 kind: ErrorKind::NotFound,
251 message: "Audio recording not found.".into(),
252 }
253 }
254
255 fn conflict(message: impl Into<String>) -> Self {
256 Self {
257 kind: ErrorKind::Conflict,
258 message: message.into(),
259 }
260 }
261
262 fn internal(error: impl std::fmt::Display) -> Self {
263 tracing::error!(%error, "AudioIngress operation failed");
264 Self {
265 kind: ErrorKind::Internal,
266 message: "An unexpected AudioIngress error occurred.".into(),
267 }
268 }
269
270 pub fn kind(&self) -> ErrorKind {
272 self.kind
273 }
274}
275
276impl std::fmt::Display for Error {
277 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 formatter.write_str(&self.message)
279 }
280}
281
282impl std::error::Error for Error {}
283
284struct TemporaryUpload(PathBuf);
285
286impl Drop for TemporaryUpload {
287 fn drop(&mut self) {
288 let _ = fs::remove_file(&self.0);
289 }
290}
291
292struct Inner {
293 root: PathBuf,
294 db: Arc<Mutex<Connection>>,
295 classifier: Arc<SpeechClassifier>,
296 transcriber: AudioTranscriber,
297 jobs: Mutex<HashMap<Uuid, TranscriptionJob>>,
298}
299
300#[derive(Clone)]
302pub struct AudioIngress {
303 inner: Arc<Inner>,
304}
305
306impl AudioIngress {
307 pub async fn open(
313 persistence_root: impl AsRef<Path>,
314 transcriber: AudioTranscriber,
315 classifier: Arc<SpeechClassifier>,
316 ) -> Result<Self, Error> {
317 let root = persistence_root.as_ref().to_path_buf();
318 ensure_private_directory(&root).map_err(Error::internal)?;
319 ensure_private_directory(&root.join(ORIGINALS_DIRECTORY)).map_err(Error::internal)?;
320
321 let connection = Connection::open(root.join(DATABASE_FILENAME)).map_err(Error::internal)?;
322 connection
323 .execute_batch(
324 "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA busy_timeout=15000;",
325 )
326 .map_err(Error::internal)?;
327 apply_migrations(&connection).map_err(Error::internal)?;
328 recover_interrupted_attempts(&connection).map_err(Error::internal)?;
329
330 let inner = Arc::new(Inner {
331 root,
332 db: Arc::new(Mutex::new(connection)),
333 classifier,
334 transcriber,
335 jobs: Mutex::new(HashMap::new()),
336 });
337 tokio::spawn(worker_loop(Arc::downgrade(&inner)));
338 Ok(Self { inner })
339 }
340
341 pub async fn submit(&self, input: AudioInput) -> Result<Submission, Error> {
343 if input.user_id.trim().is_empty() || input.user_id.chars().count() > 256 {
344 return Err(Error::invalid(
345 "User ID must contain between 1 and 256 characters.",
346 ));
347 }
348 if input.bytes.is_empty() {
349 return Err(Error::invalid("Audio bytes must not be empty."));
350 }
351 let size_bytes = i64::try_from(input.bytes.len())
352 .map_err(|_| Error::invalid("Audio is too large for this platform."))?;
353 let sha256 = format!("{:x}", Sha256::digest(&input.bytes));
354 if let Some(id) = self.recording_id_by_sha(&sha256)? {
355 return Ok(Submission {
356 recording_id: id,
357 deduplicated: true,
358 });
359 }
360
361 let upload_id = Uuid::new_v4();
362 let temporary = TemporaryUpload(self.inner.root.join(format!(".upload-{upload_id}.tmp")));
363 let mut file = tokio::fs::OpenOptions::new()
364 .write(true)
365 .create_new(true)
366 .open(&temporary.0)
367 .await
368 .map_err(Error::internal)?;
369 set_private_file(&temporary.0).map_err(Error::internal)?;
370 file.write_all(&input.bytes)
371 .await
372 .map_err(Error::internal)?;
373 file.sync_all().await.map_err(Error::internal)?;
374 drop(file);
375
376 let relative_path = format!("{ORIGINALS_DIRECTORY}/{sha256}.wav");
377 let final_path = self.inner.root.join(&relative_path);
378 if final_path.exists() {
379 tokio::fs::remove_file(&temporary.0)
380 .await
381 .map_err(Error::internal)?;
382 } else {
383 tokio::fs::rename(&temporary.0, &final_path)
384 .await
385 .map_err(Error::internal)?;
386 }
387 set_private_file(&final_path).map_err(Error::internal)?;
388 sync_file(&final_path).map_err(Error::internal)?;
389 sync_directory(final_path.parent().unwrap_or(&self.inner.root)).map_err(Error::internal)?;
390 sync_directory(&self.inner.root).map_err(Error::internal)?;
391
392 let id = Uuid::new_v4();
393 let now = Utc::now().to_rfc3339();
394 let filename = safe_filename(input.original_filename.as_deref());
395 let insert = {
396 let db = self.inner.db.lock().map_err(Error::internal)?;
397 db.execute(
398 "INSERT INTO audio_recordings(
399 id,user_id,sha256,original_filename,content_type,size_bytes,
400 source_created_at,received_at,updated_at,original_relative_path,
401 status,gemini_model,reconciliation_model,reconciliation_reasoning
402 ) VALUES(?1,?2,?3,?4,'audio/wav',?5,?6,?7,?7,?8,'uploaded',?9,?10,?11)",
403 params![
404 id.to_string(),
405 input.user_id,
406 sha256,
407 filename,
408 size_bytes,
409 input.recorded_at.to_rfc3339(),
410 now,
411 relative_path,
412 TRANSCRIPTION_MODEL,
413 RECONCILIATION_MODEL,
414 RECONCILIATION_REASONING,
415 ],
416 )
417 };
418 if let Err(error) = insert {
419 if let Some(existing) = self.recording_id_by_sha(&sha256)? {
420 return Ok(Submission {
421 recording_id: existing,
422 deduplicated: true,
423 });
424 }
425 return Err(Error::internal(error));
426 }
427 tracing::info!(recording_id=%id, %sha256, bytes=size_bytes, "Durably accepted audio");
428 Ok(Submission {
429 recording_id: id,
430 deduplicated: false,
431 })
432 }
433
434 pub fn status(&self) -> Result<Status, Error> {
436 let db = self.inner.db.lock().map_err(Error::internal)?;
437 let mut statement = db
438 .prepare(
439 "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,received_at,
440 status,gemini_model,reconciliation_model,reconciliation_reasoning,
441 attempt_count,last_error,failure_retryable,transcription_status_json,
442 final_transcript,correction_packet_json
443 FROM audio_recordings
444 ORDER BY datetime(source_created_at) DESC,datetime(received_at) DESC,id DESC",
445 )
446 .map_err(Error::internal)?;
447 let recordings = statement
448 .query_map([], row_recording_status)
449 .map_err(Error::internal)?
450 .collect::<Result<Vec<_>, _>>()
451 .map_err(Error::internal)?;
452 Ok(Status { recordings })
453 }
454
455 pub fn retry(&self, recording_id: Uuid) -> Result<(), Error> {
457 let db = self.inner.db.lock().map_err(Error::internal)?;
458 let changed = db
459 .execute(
460 "UPDATE audio_recordings
461 SET status='uploaded',attempt_count=0,next_attempt_at=NULL,last_error=NULL,
462 transcription_status_json=NULL,failure_retryable=1,updated_at=?1
463 WHERE id=?2 AND status='failed'",
464 params![Utc::now().to_rfc3339(), recording_id.to_string()],
465 )
466 .map_err(Error::internal)?;
467 if changed == 1 {
468 return Ok(());
469 }
470 let exists = db
471 .query_row(
472 "SELECT 1 FROM audio_recordings WHERE id=?1",
473 [recording_id.to_string()],
474 |row| row.get::<_, i64>(0),
475 )
476 .optional()
477 .map_err(Error::internal)?
478 .is_some();
479 if exists {
480 Err(Error::conflict("Only a failed recording can be retried."))
481 } else {
482 Err(Error::not_found())
483 }
484 }
485
486 pub fn confirm_speakers(
493 &self,
494 confirmation: RecordingConfirmation,
495 ) -> Result<CorrectionPacket, Error> {
496 let recording_id = confirmation.recording_id;
497 let db = self.inner.db.lock().map_err(Error::internal)?;
498 let stored = db
499 .query_row(
500 "SELECT status,correction_packet_json
501 FROM audio_recordings WHERE id=?1",
502 [recording_id.to_string()],
503 |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
504 )
505 .optional()
506 .map_err(Error::internal)?;
507 let Some((status, packet_json)) = stored else {
508 return Err(Error::not_found());
509 };
510 if !matches!(
511 status.as_str(),
512 "ready_for_ingress" | "ingressing" | "ingress_failed" | "complete"
513 ) {
514 return Err(Error::conflict(
515 "Speaker confirmation requires a completed recording.",
516 ));
517 }
518 let packet_json = packet_json
519 .ok_or_else(|| Error::conflict("The completed recording has no correction packet."))?;
520 let mut packet: CorrectionPacket =
521 serde_json::from_str(&packet_json).map_err(Error::internal)?;
522 identity::validate_confirmation_coverage(&packet, &confirmation).map_err(Error::invalid)?;
523
524 let previous = packet.clone();
525 apply_confirmations(&self.inner.classifier, &mut packet, &confirmation)
526 .map_err(Error::internal)?;
527 let updated_json = serde_json::to_string(&packet).map_err(Error::internal)?;
528 if let Err(error) = db.execute(
529 "UPDATE audio_recordings
530 SET correction_packet_json=?1,updated_at=?2
531 WHERE id=?3",
532 params![
533 updated_json,
534 Utc::now().to_rfc3339(),
535 recording_id.to_string()
536 ],
537 ) {
538 let rollback_errors = restore_packet_training(&self.inner.classifier, &previous);
539 if !rollback_errors.is_empty() {
540 tracing::error!(
541 recording_id=%recording_id,
542 errors=?rollback_errors,
543 "Could not fully restore classifier state after packet persistence failed"
544 );
545 }
546 return Err(Error::internal(error));
547 }
548 Ok(packet)
549 }
550
551 fn recording_id_by_sha(&self, sha256: &str) -> Result<Option<Uuid>, Error> {
552 let db = self.inner.db.lock().map_err(Error::internal)?;
553 db.query_row(
554 "SELECT id FROM audio_recordings WHERE sha256=?1",
555 [sha256],
556 |row| row.get::<_, String>(0),
557 )
558 .optional()
559 .map_err(Error::internal)?
560 .map(|id| Uuid::parse_str(&id).map_err(Error::internal))
561 .transpose()
562 }
563}
564
565fn row_recording_status(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecordingStatus> {
566 let id: String = row.get(0)?;
567 let recorded_at: String = row.get(5)?;
568 let received_at: String = row.get(6)?;
569 let durable_status: String = row.get(7)?;
570 let attempts: i64 = row.get(11)?;
571 let last_error: Option<String> = row.get(12)?;
572 let retryable: i64 = row.get(13)?;
573 let progress_json: Option<String> = row.get(14)?;
574 let transcript: Option<String> = row.get(15)?;
575 let correction_packet_json: Option<String> = row.get(16)?;
576 let parse_time = |index, value: &str| {
577 DateTime::parse_from_rfc3339(value)
578 .map(|value| value.with_timezone(&Utc))
579 .map_err(|error| {
580 rusqlite::Error::FromSqlConversionFailure(
581 index,
582 rusqlite::types::Type::Text,
583 Box::new(error),
584 )
585 })
586 };
587 let state = match durable_status.as_str() {
588 "uploaded" => RecordingState::Queued,
589 "chunking" | "transcribing" | "reconciling" => {
590 let progress = progress_json
591 .as_deref()
592 .map(serde_json::from_str)
593 .transpose()
594 .map_err(|error| {
595 rusqlite::Error::FromSqlConversionFailure(
596 14,
597 rusqlite::types::Type::Text,
598 Box::new(error),
599 )
600 })?
601 .unwrap_or_else(initial_progress);
602 RecordingState::Processing {
603 attempt: attempts.clamp(0, i64::from(u8::MAX)) as u8,
604 progress: without_results(progress),
605 }
606 }
607 "ready_for_ingress" | "ingressing" | "ingress_failed" | "complete" => {
608 RecordingState::Complete {
609 transcript: transcript.unwrap_or_default(),
610 }
611 }
612 "failed" => RecordingState::Failed {
613 attempts: attempts.clamp(0, i64::from(u8::MAX)) as u8,
614 error: last_error.unwrap_or_else(|| "Audio processing failed.".into()),
615 retryable: retryable != 0,
616 },
617 other => {
618 return Err(rusqlite::Error::FromSqlConversionFailure(
619 7,
620 rusqlite::types::Type::Text,
621 format!("unknown audio status {other:?}").into(),
622 ));
623 }
624 };
625 let correction_packet = correction_packet_json
626 .as_deref()
627 .map(serde_json::from_str)
628 .transpose()
629 .map_err(|error| {
630 rusqlite::Error::FromSqlConversionFailure(
631 16,
632 rusqlite::types::Type::Text,
633 Box::new(error),
634 )
635 })?;
636 Ok(RecordingStatus {
637 id: Uuid::parse_str(&id).map_err(|error| {
638 rusqlite::Error::FromSqlConversionFailure(
639 0,
640 rusqlite::types::Type::Text,
641 Box::new(error),
642 )
643 })?,
644 user_id: row.get(1)?,
645 sha256: row.get(2)?,
646 original_filename: row.get(3)?,
647 size_bytes: u64::try_from(row.get::<_, i64>(4)?).map_err(|error| {
648 rusqlite::Error::FromSqlConversionFailure(
649 4,
650 rusqlite::types::Type::Integer,
651 Box::new(error),
652 )
653 })?,
654 recorded_at: parse_time(5, &recorded_at)?,
655 received_at: parse_time(6, &received_at)?,
656 transcription_model: row.get(8)?,
657 reconciliation_model: row.get(9)?,
658 reconciliation_reasoning: row.get(10)?,
659 state,
660 correction_packet,
661 })
662}
663
664async fn worker_loop(inner: Weak<Inner>) {
665 loop {
666 let Some(inner) = inner.upgrade() else {
667 return;
668 };
669 let worked = match process_next_recording(&inner).await {
670 Ok(worked) => worked,
671 Err(error) => {
672 tracing::error!(error=%error, "AudioIngress worker iteration failed");
673 false
674 }
675 };
676 drop(inner);
677 tokio::time::sleep(if worked {
678 Duration::from_millis(100)
679 } else {
680 Duration::from_secs(5)
681 })
682 .await;
683 }
684}
685
686#[derive(Debug)]
687struct WorkRecording {
688 id: Uuid,
689 user_id: String,
690 sha256: String,
691 original_filename: String,
692 size_bytes: u64,
693 recorded_at: DateTime<Utc>,
694 original_relative_path: String,
695 attempt_count: i64,
696}
697
698async fn process_next_recording(inner: &Inner) -> anyhow::Result<bool> {
699 let recording = {
700 let db = inner
701 .db
702 .lock()
703 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
704 fetch_work_recording(&db)?
705 };
706 let Some(recording) = recording else {
707 return Ok(false);
708 };
709 poll_transcription(inner, recording).await?;
710 Ok(true)
711}
712
713fn fetch_work_recording(db: &Connection) -> anyhow::Result<Option<WorkRecording>> {
714 db.query_row(
715 "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,
716 original_relative_path,attempt_count
717 FROM audio_recordings
718 WHERE status IN ('uploaded','chunking','transcribing','reconciling')
719 AND (next_attempt_at IS NULL OR datetime(next_attempt_at)<=datetime('now'))
720 ORDER BY datetime(received_at),id
721 LIMIT 1",
722 [],
723 |row| {
724 Ok((
725 row.get::<_, String>(0)?,
726 row.get::<_, String>(1)?,
727 row.get::<_, String>(2)?,
728 row.get::<_, String>(3)?,
729 row.get::<_, i64>(4)?,
730 row.get::<_, String>(5)?,
731 row.get::<_, String>(6)?,
732 row.get::<_, i64>(7)?,
733 ))
734 },
735 )
736 .optional()?
737 .map(
738 |(
739 id,
740 user_id,
741 sha256,
742 original_filename,
743 size_bytes,
744 recorded_at,
745 original_relative_path,
746 attempt_count,
747 )| {
748 Ok(WorkRecording {
749 id: Uuid::parse_str(&id)?,
750 user_id,
751 sha256,
752 original_filename,
753 size_bytes: u64::try_from(size_bytes).context("stored audio size is negative")?,
754 recorded_at: DateTime::parse_from_rfc3339(&recorded_at)
755 .context("stored recording time is invalid")?
756 .with_timezone(&Utc),
757 original_relative_path,
758 attempt_count,
759 })
760 },
761 )
762 .transpose()
763}
764
765async fn poll_transcription(inner: &Inner, mut recording: WorkRecording) -> anyhow::Result<()> {
766 let existing = inner
767 .jobs
768 .lock()
769 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
770 .get(&recording.id)
771 .cloned();
772 let job = if let Some(job) = existing {
773 job
774 } else {
775 if recording.attempt_count >= FAILURE_LIMIT {
776 mark_failed(
777 inner,
778 recording.id,
779 recording.attempt_count,
780 true,
781 "Audio transcription exhausted its five automatic attempts.",
782 None,
783 )?;
784 return Ok(());
785 }
786 let source = inner.root.join(&recording.original_relative_path);
787 let audio = tokio::fs::read(&source)
788 .await
789 .with_context(|| format!("reading retained audio {}", source.display()))?;
790 recording.attempt_count += 1;
791 {
792 let db = inner
793 .db
794 .lock()
795 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
796 db.execute(
797 "UPDATE audio_recordings
798 SET status='chunking',attempt_count=?1,next_attempt_at=NULL,last_error=NULL,
799 failure_retryable=1,updated_at=?2
800 WHERE id=?3",
801 params![
802 recording.attempt_count,
803 Utc::now().to_rfc3339(),
804 recording.id.to_string()
805 ],
806 )?;
807 }
808
809 let classification = ClassificationContext {
810 recording_id: recording.id,
811 user_id: recording.user_id.clone(),
812 sha256: recording.sha256.clone(),
813 original_filename: recording.original_filename.clone(),
814 size_bytes: recording.size_bytes,
815 recorded_at: recording.recorded_at,
816 classifier: inner.classifier.clone(),
817 };
818
819 let cache_db = inner.db.clone();
820 let cache_recording_id = recording.id;
821 let cache_classification = classification.clone();
822 let piece_cache: PieceCache = Arc::new(move |plan| {
823 load_cached_transcript_piece(&cache_db, cache_recording_id, plan, &cache_classification)
824 });
825
826 let sink_db = inner.db.clone();
827 let sink_recording_id = recording.id;
828 let attempt_id = Uuid::new_v4().to_string();
829 let piece_sink: PieceSink = Arc::new(move |piece| {
830 persist_transcript_piece(&sink_db, sink_recording_id, &attempt_id, piece)
831 });
832
833 let job = inner.transcriber.transcribe_durably(
834 recording.user_id.clone(),
835 audio,
836 piece_cache,
837 piece_sink,
838 classification,
839 );
840 inner
841 .jobs
842 .lock()
843 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
844 .insert(recording.id, job.clone());
845 job
846 };
847
848 let snapshot = job.status();
849 persist_progress(inner, recording.id, &snapshot)?;
850 match snapshot.state {
851 JobState::Queued | JobState::Running => Ok(()),
852 JobState::Completed => {
853 let transcript = snapshot
854 .transcript
855 .clone()
856 .context("completed transcription omitted its transcript")?;
857 ensure!(
858 !transcript.trim().is_empty(),
859 "completed transcription is empty"
860 );
861 let packet = snapshot
862 .correction_packet
863 .clone()
864 .context("completed durable transcription omitted its correction packet")?;
865 ensure!(
866 packet.recording_id == recording.id,
867 "completed correction packet belongs to another recording"
868 );
869 let packet_json = serde_json::to_string(&packet)?;
870 let progress = serde_json::to_string(&without_results(snapshot))?;
871 {
872 let db = inner
873 .db
874 .lock()
875 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
876 db.execute(
877 "UPDATE audio_recordings
878 SET status='ready_for_ingress',final_transcript=?1,
879 correction_packet_json=?2,transcription_status_json=?3,
880 next_attempt_at=NULL,last_error=NULL,failure_retryable=1,updated_at=?4
881 WHERE id=?5",
882 params![
883 transcript.trim(),
884 packet_json,
885 progress,
886 Utc::now().to_rfc3339(),
887 recording.id.to_string()
888 ],
889 )?;
890 }
891 remove_job(inner, recording.id)?;
892 tracing::info!(
893 recording_id=%recording.id,
894 clean=packet.clean,
895 "Audio transcript and correction packet completed"
896 );
897 Ok(())
898 }
899 JobState::Failed => {
900 let error = snapshot
901 .steps
902 .iter()
903 .find(|step| step.state == StepState::Failed)
904 .and_then(|step| step.error.as_ref());
905 let message = error
906 .map(|error| error.message.clone())
907 .unwrap_or_else(|| "Audio transcription failed without detail.".into());
908 let retryable = error.is_none_or(|error| error.retryable);
909 record_attempt_failure(
910 inner,
911 recording.id,
912 recording.attempt_count,
913 retryable,
914 &message,
915 Some(snapshot),
916 )?;
917 remove_job(inner, recording.id)
918 }
919 }
920}
921
922fn record_attempt_failure(
923 inner: &Inner,
924 id: Uuid,
925 attempts: i64,
926 retryable: bool,
927 message: &str,
928 progress: Option<TranscriptionStatus>,
929) -> anyhow::Result<()> {
930 if !retryable || attempts >= FAILURE_LIMIT {
931 return mark_failed(inner, id, attempts, retryable, message, progress);
932 }
933 let db = inner
934 .db
935 .lock()
936 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
937 db.execute(
938 "UPDATE audio_recordings
939 SET status='uploaded',next_attempt_at=?1,last_error=?2,failure_retryable=1,
940 transcription_status_json=?3,updated_at=?4
941 WHERE id=?5",
942 params![
943 (Utc::now() + ChronoDuration::seconds(RETRY_DELAY_SECONDS)).to_rfc3339(),
944 concise(message, 2_000),
945 progress
946 .map(without_results)
947 .map(|progress| serde_json::to_string(&progress))
948 .transpose()?,
949 Utc::now().to_rfc3339(),
950 id.to_string()
951 ],
952 )?;
953 tracing::warn!(recording_id=%id, attempt=attempts, "Audio transcription will retry");
954 Ok(())
955}
956
957fn mark_failed(
958 inner: &Inner,
959 id: Uuid,
960 attempts: i64,
961 retryable: bool,
962 message: &str,
963 progress: Option<TranscriptionStatus>,
964) -> anyhow::Result<()> {
965 let db = inner
966 .db
967 .lock()
968 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
969 db.execute(
970 "UPDATE audio_recordings
971 SET status='failed',attempt_count=?1,next_attempt_at=NULL,last_error=?2,
972 failure_retryable=?3,transcription_status_json=?4,updated_at=?5
973 WHERE id=?6",
974 params![
975 attempts,
976 concise(message, 2_000),
977 i64::from(retryable),
978 progress
979 .map(without_results)
980 .map(|progress| serde_json::to_string(&progress))
981 .transpose()?,
982 Utc::now().to_rfc3339(),
983 id.to_string()
984 ],
985 )?;
986 tracing::error!(recording_id=%id, attempts, retryable, "Audio transcription stopped");
987 Ok(())
988}
989
990fn persist_progress(inner: &Inner, id: Uuid, snapshot: &TranscriptionStatus) -> anyhow::Result<()> {
991 let durable_status = transcription_stage(snapshot);
992 let serialized = serde_json::to_string(&without_results(snapshot.clone()))?;
993 let db = inner
994 .db
995 .lock()
996 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
997 db.execute(
998 "UPDATE audio_recordings
999 SET status=?1,transcription_status_json=?2,updated_at=?3
1000 WHERE id=?4 AND (status<>?1 OR COALESCE(transcription_status_json,'')<>?2)",
1001 params![
1002 durable_status,
1003 serialized,
1004 Utc::now().to_rfc3339(),
1005 id.to_string()
1006 ],
1007 )?;
1008 Ok(())
1009}
1010
1011fn load_cached_transcript_piece(
1012 db: &Mutex<Connection>,
1013 recording_id: Uuid,
1014 plan: ChunkPlan,
1015 classification: &ClassificationContext,
1016) -> anyhow::Result<Option<ChunkTranscript>> {
1017 ensure!(
1018 recording_id == classification.recording_id,
1019 "cache classification context belongs to another recording"
1020 );
1021 let piece_index = i64::try_from(plan.index).context("piece index exceeds SQLite limits")?;
1022 let piece_count = i64::try_from(plan.total).context("piece count exceeds SQLite limits")?;
1023 let audio_start_ms =
1024 i64::try_from(plan.start_ms).context("piece start exceeds SQLite limits")?;
1025 let audio_end_ms = i64::try_from(plan.end_ms).context("piece end exceeds SQLite limits")?;
1026 let stored = {
1027 let db = db
1028 .lock()
1029 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1030 db.query_row(
1031 "SELECT raw_gemini_response,parsed_json
1032 FROM audio_transcript_pieces
1033 WHERE recording_id=?1
1034 AND cache_revision=?2
1035 AND piece_index=?3
1036 AND piece_count=?4
1037 AND audio_start_ms=?5
1038 AND audio_end_ms=?6
1039 ORDER BY datetime(created_at) DESC,attempt_id DESC
1040 LIMIT 1",
1041 params![
1042 recording_id.to_string(),
1043 PIECE_CACHE_REVISION,
1044 piece_index,
1045 piece_count,
1046 audio_start_ms,
1047 audio_end_ms,
1048 ],
1049 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
1050 )
1051 .optional()?
1052 };
1053 let Some((raw_gemini_response, parsed_json)) = stored else {
1054 return Ok(None);
1055 };
1056 ensure!(
1057 !raw_gemini_response.trim().is_empty(),
1058 "cached piece omitted its raw Gemini response"
1059 );
1060 let duration_seconds = (plan.end_ms - plan.start_ms) as f64 / 1_000.0;
1061 let parsed = parse_and_validate_chunk(&parsed_json, duration_seconds)
1062 .context("cached parsed speaker analysis is invalid")?;
1063 let (observations, clean) = classify_speakers(classification, plan.index, &parsed)
1064 .context("reclassifying cached speaker rows failed")?;
1065 Ok(Some(ChunkTranscript {
1066 plan,
1067 raw_gemini_response,
1068 parsed,
1069 observations,
1070 clean,
1071 }))
1072}
1073
1074fn persist_transcript_piece(
1075 db: &Mutex<Connection>,
1076 recording_id: Uuid,
1077 attempt_id: &str,
1078 piece: &ChunkTranscript,
1079) -> anyhow::Result<()> {
1080 ensure!(!attempt_id.is_empty(), "piece attempt identity is empty");
1081 let piece_index =
1082 i64::try_from(piece.plan.index).context("piece index exceeds SQLite limits")?;
1083 let piece_count =
1084 i64::try_from(piece.plan.total).context("piece count exceeds SQLite limits")?;
1085 let audio_start_ms =
1086 i64::try_from(piece.plan.start_ms).context("piece start exceeds SQLite limits")?;
1087 let audio_end_ms =
1088 i64::try_from(piece.plan.end_ms).context("piece end exceeds SQLite limits")?;
1089 let parsed_json = serde_json::to_string(&piece.parsed)?;
1090 let db = db
1091 .lock()
1092 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1093 db.execute(
1094 "INSERT INTO audio_transcript_pieces(
1095 recording_id,attempt_id,cache_revision,piece_index,piece_count,
1096 audio_start_ms,audio_end_ms,transcript_json,raw_gemini_response,
1097 parsed_json,created_at
1098 ) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?8,?10)
1099 ON CONFLICT(recording_id,attempt_id,piece_index) DO UPDATE SET
1100 cache_revision=excluded.cache_revision,
1101 piece_count=excluded.piece_count,
1102 audio_start_ms=excluded.audio_start_ms,
1103 audio_end_ms=excluded.audio_end_ms,
1104 transcript_json=excluded.transcript_json,
1105 raw_gemini_response=excluded.raw_gemini_response,
1106 parsed_json=excluded.parsed_json",
1107 params![
1108 recording_id.to_string(),
1109 attempt_id,
1110 PIECE_CACHE_REVISION,
1111 piece_index,
1112 piece_count,
1113 audio_start_ms,
1114 audio_end_ms,
1115 parsed_json,
1116 piece.raw_gemini_response,
1117 Utc::now().to_rfc3339(),
1118 ],
1119 )?;
1120 Ok(())
1121}
1122
1123fn remove_job(inner: &Inner, id: Uuid) -> anyhow::Result<()> {
1124 inner
1125 .jobs
1126 .lock()
1127 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1128 .remove(&id);
1129 Ok(())
1130}
1131
1132fn transcription_stage(snapshot: &TranscriptionStatus) -> &'static str {
1133 let plan_complete = snapshot
1134 .steps
1135 .iter()
1136 .any(|entry| entry.step == Step::PlanChunks && entry.state == StepState::Completed);
1137 if !plan_complete {
1138 return "chunking";
1139 }
1140 let chunks_complete = snapshot
1141 .steps
1142 .iter()
1143 .filter(|entry| {
1144 matches!(
1145 entry.step,
1146 Step::TranscribeChunk { .. } | Step::ParseChunk { .. }
1147 )
1148 })
1149 .all(|entry| entry.state == StepState::Completed);
1150 if chunks_complete {
1151 "reconciling"
1152 } else {
1153 "transcribing"
1154 }
1155}
1156
1157fn without_results(mut status: TranscriptionStatus) -> TranscriptionStatus {
1158 status.transcript = None;
1159 status.correction_packet = None;
1160 status
1161}
1162
1163fn initial_progress() -> TranscriptionStatus {
1164 TranscriptionStatus {
1165 state: JobState::Queued,
1166 steps: Vec::new(),
1167 transcript: None,
1168 correction_packet: None,
1169 }
1170}
1171
1172fn recover_interrupted_attempts(connection: &Connection) -> rusqlite::Result<()> {
1173 connection.execute(
1174 "UPDATE audio_recordings
1175 SET status=CASE WHEN attempt_count>=?1 THEN 'failed' ELSE 'uploaded' END,
1176 next_attempt_at=NULL,
1177 last_error=CASE WHEN attempt_count>=?1
1178 THEN 'Audio transcription stopped after its fifth attempt was interrupted.'
1179 ELSE 'Audio transcription was interrupted and will restart automatically.'
1180 END,
1181 failure_retryable=1,
1182 updated_at=?2
1183 WHERE status IN ('chunking','transcribing','reconciling')",
1184 params![FAILURE_LIMIT, Utc::now().to_rfc3339()],
1185 )?;
1186 Ok(())
1187}
1188
1189fn apply_migrations(connection: &Connection) -> anyhow::Result<()> {
1190 let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
1191 ensure!(
1192 version <= LATEST_SCHEMA_VERSION,
1193 "audio-ingress database schema version {version} is newer than supported version {LATEST_SCHEMA_VERSION}"
1194 );
1195 if version == 0 {
1196 let has_recordings = connection.query_row(
1197 "SELECT EXISTS(
1198 SELECT 1 FROM sqlite_schema
1199 WHERE type='table' AND name='audio_recordings'
1200 )",
1201 [],
1202 |row| row.get::<_, i64>(0),
1203 )? == 1;
1204 if !has_recordings {
1205 connection.execute_batch(FRESH_SCHEMA)?;
1206 return Ok(());
1207 }
1208 }
1209 if version < 1 {
1210 connection.execute_batch(INITIAL_MIGRATION)?;
1211 }
1212 if version < 2 {
1213 connection.execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)?;
1214 }
1215 if version < 3 {
1216 connection.execute_batch(TRANSCRIPTION_STATUS_MIGRATION)?;
1217 }
1218 if version < 4 {
1219 connection.execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)?;
1220 }
1221 if version < 5 {
1222 connection.execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)?;
1223 }
1224 if version < 6 {
1225 connection.execute_batch(STANDALONE_LIBRARY_MIGRATION)?;
1226 }
1227 if version < 7 {
1228 connection.execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)?;
1229 }
1230 if version < 8 {
1231 connection.execute_batch(UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION)?;
1232 }
1233 if version < 9 {
1234 connection.execute_batch(USAGE_USER_MIGRATION)?;
1235 }
1236 if version < 10 {
1237 connection.execute_batch(SPEAKER_CORRECTION_PACKETS_MIGRATION)?;
1238 }
1239 Ok(())
1240}
1241
1242fn ensure_private_directory(path: &Path) -> anyhow::Result<()> {
1243 fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
1244 #[cfg(unix)]
1245 {
1246 use std::os::unix::fs::PermissionsExt;
1247 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1248 .with_context(|| format!("setting private permissions on {}", path.display()))?;
1249 }
1250 Ok(())
1251}
1252
1253fn set_private_file(path: &Path) -> anyhow::Result<()> {
1254 #[cfg(unix)]
1255 {
1256 use std::os::unix::fs::PermissionsExt;
1257 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
1258 .with_context(|| format!("setting private permissions on {}", path.display()))?;
1259 }
1260 Ok(())
1261}
1262
1263fn sync_file(path: &Path) -> anyhow::Result<()> {
1264 fs::OpenOptions::new()
1265 .read(true)
1266 .write(true)
1267 .open(path)
1268 .with_context(|| format!("opening {} for sync", path.display()))?
1269 .sync_all()
1270 .with_context(|| format!("syncing {}", path.display()))
1271}
1272
1273fn sync_directory(path: &Path) -> anyhow::Result<()> {
1274 #[cfg(unix)]
1275 fs::File::open(path)
1276 .with_context(|| format!("opening directory {} for sync", path.display()))?
1277 .sync_all()
1278 .with_context(|| format!("syncing directory {}", path.display()))?;
1279 Ok(())
1280}
1281
1282fn safe_filename(value: Option<&str>) -> String {
1283 let name = value
1284 .and_then(|value| Path::new(value).file_name())
1285 .and_then(|value| value.to_str())
1286 .unwrap_or("audio.wav");
1287 let clean = name
1288 .chars()
1289 .map(|character| {
1290 if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
1291 character
1292 } else {
1293 '_'
1294 }
1295 })
1296 .take(200)
1297 .collect::<String>();
1298 if clean.is_empty() {
1299 "audio.wav".into()
1300 } else {
1301 clean
1302 }
1303}
1304
1305fn concise(value: &str, limit: usize) -> String {
1306 let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
1307 let bounded = normalized.chars().take(limit).collect::<String>();
1308 if bounded.is_empty() {
1309 "Audio transcription failed without an error message.".into()
1310 } else {
1311 bounded
1312 }
1313}
1314
1315#[cfg(test)]
1316mod tests {
1317 use super::*;
1318 use std::sync::atomic::{AtomicU64, Ordering};
1319
1320 static NEXT_PATH: AtomicU64 = AtomicU64::new(0);
1321
1322 fn database() -> Connection {
1323 let connection = Connection::open_in_memory().unwrap();
1324 apply_migrations(&connection).unwrap();
1325 connection
1326 }
1327
1328 fn classifier_path(label: &str) -> PathBuf {
1329 std::env::temp_dir().join(format!(
1330 "kcode-audio-ingress-lib-{}-{label}-{}.sqlite3",
1331 std::process::id(),
1332 NEXT_PATH.fetch_add(1, Ordering::Relaxed)
1333 ))
1334 }
1335
1336 fn root_path(label: &str) -> PathBuf {
1337 std::env::temp_dir().join(format!(
1338 "kcode-audio-ingress-lib-{}-{label}-{}",
1339 std::process::id(),
1340 NEXT_PATH.fetch_add(1, Ordering::Relaxed)
1341 ))
1342 }
1343
1344 fn remove_database(path: &Path) {
1345 for suffix in ["", "-wal", "-shm"] {
1346 let mut value = path.as_os_str().to_os_string();
1347 value.push(suffix);
1348 let _ = fs::remove_file(PathBuf::from(value));
1349 }
1350 }
1351
1352 fn transcriber() -> AudioTranscriber {
1353 let audio: AudioChunkCall = Arc::new(|_| {
1354 Box::pin(async { Err(IntelligenceError::new("unexpected audio call", false)) })
1355 });
1356 let text: TextGenerationCall = Arc::new(|_| {
1357 Box::pin(async { Err(IntelligenceError::new("unexpected text call", false)) })
1358 });
1359 AudioTranscriber::new(audio, text)
1360 }
1361
1362 fn insert_recording(connection: &Connection, id: Uuid, status: &str) {
1363 let has_user_id = connection
1364 .prepare("SELECT 1 FROM pragma_table_info('audio_recordings') WHERE name='user_id'")
1365 .unwrap()
1366 .exists([])
1367 .unwrap();
1368 let sql = if has_user_id {
1369 "INSERT INTO audio_recordings(
1370 id,user_id,sha256,original_filename,content_type,size_bytes,source_created_at,
1371 received_at,updated_at,original_relative_path,status,gemini_model,
1372 reconciliation_model,reconciliation_reasoning
1373 ) VALUES(?1,'test-user',?2,'note.wav','audio/wav',4,?3,?3,?3,?4,?5,?6,?7,?8)"
1374 } else {
1375 "INSERT INTO audio_recordings(
1376 id,sha256,original_filename,content_type,size_bytes,source_created_at,
1377 received_at,updated_at,original_relative_path,status,gemini_model,
1378 reconciliation_model,reconciliation_reasoning
1379 ) VALUES(?1,?2,'note.wav','audio/wav',4,?3,?3,?3,?4,?5,?6,?7,?8)"
1380 };
1381 connection
1382 .execute(
1383 sql,
1384 params![
1385 id.to_string(),
1386 format!("{:064x}", 1),
1387 "2026-01-01T00:00:00Z",
1388 format!("originals/{id}.wav"),
1389 status,
1390 TRANSCRIPTION_MODEL,
1391 RECONCILIATION_MODEL,
1392 RECONCILIATION_REASONING,
1393 ],
1394 )
1395 .unwrap();
1396 }
1397
1398 fn sample_row() -> FeatureRow {
1399 FeatureRow {
1400 accent_variety: "stan1293 Standard American English".into(),
1401 perceived_age: 36.0,
1402 vocal_gender_presentation: 55.0,
1403 median_f0_hz: 145.0,
1404 formant_dispersion_hz: 1050.0,
1405 vai: 1.1,
1406 hypernasality: 0.0,
1407 creaky_phonation_percent: 5.0,
1408 rhotic_realization: "[ɹ] alveolar approximant".into(),
1409 word_initial_stressed_prevocalic_t_vot_ms: 58.0,
1410 breathiness: 8.0,
1411 roughness: 4.0,
1412 f0_pitch_span_semitones: 10.0,
1413 articulation_rate_syllables_per_second: 4.1,
1414 npvi_v: 48.0,
1415 cefr: Cefr::C2,
1416 foreign_accentedness: 1.0,
1417 unstressed_vowel_reduction_percent: 75.0,
1418 lateral_realization: "mixed".into(),
1419 filled_pauses_per_100_words: 1.0,
1420 s_realization: "laminal [s̻]".into(),
1421 lexical_stress_accuracy_percent: 99.0,
1422 monophthongization_percent: 2.0,
1423 consonant_cluster_reduction_percent: 1.0,
1424 }
1425 }
1426
1427 fn sample_parsed() -> ParsedChunk {
1428 ParsedChunk {
1429 utterances: vec![ParsedUtterance {
1430 speaker: "Speaker A".into(),
1431 language: "eng".into(),
1432 original_text: "Hello.".into(),
1433 english_translation: String::new(),
1434 corrected_natural_text: None,
1435 coaching: Vec::new(),
1436 annotations: Vec::new(),
1437 }],
1438 notes: vec!["Clear recording.".into()],
1439 clip_valid: true,
1440 clip_validity_reason: None,
1441 speakers: vec![ParsedSpeaker {
1442 local_label: "Speaker A".into(),
1443 primary_language: "eng".into(),
1444 feature_row: sample_row(),
1445 }],
1446 }
1447 }
1448
1449 fn sample_piece() -> ChunkTranscript {
1450 ChunkTranscript {
1451 plan: ChunkPlan {
1452 index: 0,
1453 total: 2,
1454 start_ms: 0,
1455 end_ms: 120_000,
1456 },
1457 raw_gemini_response: "complete raw Gemini response".into(),
1458 parsed: sample_parsed(),
1459 observations: Vec::new(),
1460 clean: false,
1461 }
1462 }
1463
1464 fn classification_context(
1465 recording_id: Uuid,
1466 classifier: Arc<SpeechClassifier>,
1467 ) -> ClassificationContext {
1468 ClassificationContext {
1469 recording_id,
1470 user_id: "test-user".into(),
1471 sha256: format!("{:064x}", 1),
1472 original_filename: "note.wav".into(),
1473 size_bytes: 4,
1474 recorded_at: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1475 .unwrap()
1476 .with_timezone(&Utc),
1477 classifier,
1478 }
1479 }
1480
1481 #[test]
1482 fn fresh_schema_contains_recordings_durable_pieces_and_packet_columns() {
1483 let connection = database();
1484 let tables = connection
1485 .prepare(
1486 "SELECT name FROM sqlite_schema
1487 WHERE type='table' AND name NOT LIKE 'sqlite_%'
1488 ORDER BY name",
1489 )
1490 .unwrap()
1491 .query_map([], |row| row.get::<_, String>(0))
1492 .unwrap()
1493 .collect::<Result<Vec<_>, _>>()
1494 .unwrap();
1495 let version: i64 = connection
1496 .query_row("PRAGMA user_version", [], |row| row.get(0))
1497 .unwrap();
1498 let piece_columns = connection
1499 .prepare("SELECT name FROM pragma_table_info('audio_transcript_pieces')")
1500 .unwrap()
1501 .query_map([], |row| row.get::<_, String>(0))
1502 .unwrap()
1503 .collect::<Result<Vec<_>, _>>()
1504 .unwrap();
1505 let recording_columns = connection
1506 .prepare("SELECT name FROM pragma_table_info('audio_recordings')")
1507 .unwrap()
1508 .query_map([], |row| row.get::<_, String>(0))
1509 .unwrap()
1510 .collect::<Result<Vec<_>, _>>()
1511 .unwrap();
1512 assert_eq!(tables, vec!["audio_recordings", "audio_transcript_pieces"]);
1513 assert!(piece_columns.contains(&"raw_gemini_response".into()));
1514 assert!(piece_columns.contains(&"parsed_json".into()));
1515 assert!(recording_columns.contains(&"correction_packet_json".into()));
1516 assert_eq!(version, LATEST_SCHEMA_VERSION);
1517 }
1518
1519 #[tokio::test]
1520 async fn open_uses_the_injected_classifier_without_creating_a_private_database() {
1521 let root = root_path("shared-classifier-root");
1522 let classifier_path = classifier_path("shared-classifier");
1523 let classifier = Arc::new(SpeechClassifier::open(&classifier_path).unwrap());
1524
1525 let ingress = AudioIngress::open(&root, transcriber(), classifier.clone())
1526 .await
1527 .unwrap();
1528
1529 assert!(Arc::ptr_eq(&ingress.inner.classifier, &classifier));
1530 assert!(!root.join("speaker-classification.sqlite3").exists());
1531
1532 drop(ingress);
1533 tokio::task::yield_now().await;
1534 let _ = fs::remove_dir_all(&root);
1535 drop(classifier);
1536 remove_database(&classifier_path);
1537 }
1538
1539 #[test]
1540 fn version_five_databases_upgrade_without_losing_legacy_queue_data() {
1541 let connection = Connection::open_in_memory().unwrap();
1542 connection.execute_batch(INITIAL_MIGRATION).unwrap();
1543 connection
1544 .execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)
1545 .unwrap();
1546 connection
1547 .execute_batch(TRANSCRIPTION_STATUS_MIGRATION)
1548 .unwrap();
1549 connection
1550 .execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)
1551 .unwrap();
1552 connection
1553 .execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)
1554 .unwrap();
1555 let id = Uuid::new_v4();
1556 insert_recording(&connection, id, "failed");
1557
1558 apply_migrations(&connection).unwrap();
1559
1560 let version: i64 = connection
1561 .query_row("PRAGMA user_version", [], |row| row.get(0))
1562 .unwrap();
1563 let retryable: i64 = connection
1564 .query_row(
1565 "SELECT failure_retryable FROM audio_recordings WHERE id=?1",
1566 [id.to_string()],
1567 |row| row.get(0),
1568 )
1569 .unwrap();
1570 let legacy_queue_exists: i64 = connection
1571 .query_row(
1572 "SELECT EXISTS(
1573 SELECT 1 FROM sqlite_schema
1574 WHERE type='table' AND name='audio_ingress_pieces'
1575 )",
1576 [],
1577 |row| row.get(0),
1578 )
1579 .unwrap();
1580 assert_eq!(version, LATEST_SCHEMA_VERSION);
1581 assert_eq!(retryable, 1);
1582 assert_eq!(legacy_queue_exists, 1);
1583 }
1584
1585 #[test]
1586 fn version_seven_piece_rows_migrate_but_cannot_hit_the_new_cache() {
1587 let connection = Connection::open_in_memory().unwrap();
1588 connection.execute_batch(INITIAL_MIGRATION).unwrap();
1589 connection
1590 .execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)
1591 .unwrap();
1592 connection
1593 .execute_batch(TRANSCRIPTION_STATUS_MIGRATION)
1594 .unwrap();
1595 connection
1596 .execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)
1597 .unwrap();
1598 connection
1599 .execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)
1600 .unwrap();
1601 connection
1602 .execute_batch(STANDALONE_LIBRARY_MIGRATION)
1603 .unwrap();
1604 connection
1605 .execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)
1606 .unwrap();
1607 let id = Uuid::new_v4();
1608 insert_recording(&connection, id, "transcribing");
1609 connection
1610 .execute(
1611 "INSERT INTO audio_transcript_pieces(
1612 recording_id,attempt,piece_index,piece_count,audio_start_ms,
1613 audio_end_ms,transcript_json,created_at
1614 ) VALUES(?1,1,0,2,0,120000,?2,?3)",
1615 params![
1616 id.to_string(),
1617 r#"{"utterances":[{"original_text":"hello"}]}"#,
1618 "2026-01-01T00:00:00Z",
1619 ],
1620 )
1621 .unwrap();
1622
1623 apply_migrations(&connection).unwrap();
1624
1625 let migrated: (String, String, String, String) = connection
1626 .query_row(
1627 "SELECT attempt_id,cache_revision,raw_gemini_response,parsed_json
1628 FROM audio_transcript_pieces
1629 WHERE recording_id=?1 AND piece_index=0",
1630 [id.to_string()],
1631 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1632 )
1633 .unwrap();
1634 assert_eq!(migrated.0, "version-7-attempt-1");
1635 assert_eq!(migrated.1, "gemini-3.1-pro-transcription-v1");
1636 assert_ne!(migrated.1, PIECE_CACHE_REVISION);
1637 assert_eq!(migrated.2, "");
1638 assert_eq!(migrated.3, "");
1639 }
1640
1641 #[test]
1642 fn status_exposes_a_completed_correction_packet() {
1643 let connection = database();
1644 let id = Uuid::new_v4();
1645 insert_recording(&connection, id, "ready_for_ingress");
1646 let packet = CorrectionPacket {
1647 recording_id: id,
1648 user_id: "test-user".into(),
1649 sha256: format!("{:064x}", 1),
1650 original_filename: "note.wav".into(),
1651 size_bytes: 4,
1652 recorded_at: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1653 .unwrap()
1654 .with_timezone(&Utc),
1655 clean: false,
1656 chunk_count: 1,
1657 chunks: Vec::new(),
1658 confirmation_state: ConfirmationState::Unconfirmed,
1659 };
1660 connection
1661 .execute(
1662 "UPDATE audio_recordings
1663 SET final_transcript='hello',correction_packet_json=?1 WHERE id=?2",
1664 params![serde_json::to_string(&packet).unwrap(), id.to_string()],
1665 )
1666 .unwrap();
1667 let status = connection
1668 .query_row(
1669 "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,received_at,
1670 status,gemini_model,reconciliation_model,reconciliation_reasoning,
1671 attempt_count,last_error,failure_retryable,transcription_status_json,
1672 final_transcript,correction_packet_json
1673 FROM audio_recordings WHERE id=?1",
1674 [id.to_string()],
1675 row_recording_status,
1676 )
1677 .unwrap();
1678 assert!(matches!(
1679 status.state,
1680 RecordingState::Complete { ref transcript } if transcript == "hello"
1681 ));
1682 assert_eq!(status.correction_packet.unwrap().recording_id, id);
1683 }
1684
1685 #[test]
1686 fn transcript_pieces_preserve_raw_and_parsed_data_attempt_scoped() {
1687 let connection = database();
1688 let id = Uuid::new_v4();
1689 insert_recording(&connection, id, "transcribing");
1690 let db = Mutex::new(connection);
1691 let piece = sample_piece();
1692 persist_transcript_piece(&db, id, "attempt-a", &piece).unwrap();
1693 persist_transcript_piece(&db, id, "attempt-b", &piece).unwrap();
1694
1695 let db = db.lock().unwrap();
1696 let rows: i64 = db
1697 .query_row(
1698 "SELECT COUNT(*) FROM audio_transcript_pieces WHERE recording_id=?1",
1699 [id.to_string()],
1700 |row| row.get(0),
1701 )
1702 .unwrap();
1703 let stored: (String, String) = db
1704 .query_row(
1705 "SELECT raw_gemini_response,parsed_json
1706 FROM audio_transcript_pieces
1707 WHERE recording_id=?1 AND attempt_id='attempt-a' AND piece_index=0",
1708 [id.to_string()],
1709 |row| Ok((row.get(0)?, row.get(1)?)),
1710 )
1711 .unwrap();
1712 assert_eq!(rows, 2);
1713 assert_eq!(stored.0, piece.raw_gemini_response);
1714 assert_eq!(
1715 serde_json::from_str::<ParsedChunk>(&stored.1).unwrap(),
1716 piece.parsed
1717 );
1718 }
1719
1720 #[test]
1721 fn cache_reuses_only_exact_matching_new_revision_piece_plans() {
1722 let connection = database();
1723 let id = Uuid::new_v4();
1724 insert_recording(&connection, id, "transcribing");
1725 let db = Mutex::new(connection);
1726 let piece = sample_piece();
1727 persist_transcript_piece(&db, id, "attempt-a", &piece).unwrap();
1728
1729 let path = classifier_path("cache");
1730 let classifier = Arc::new(SpeechClassifier::open(&path).unwrap());
1731 let context = classification_context(id, classifier);
1732 let exact = load_cached_transcript_piece(&db, id, piece.plan, &context).unwrap();
1733 let changed = load_cached_transcript_piece(
1734 &db,
1735 id,
1736 ChunkPlan {
1737 end_ms: piece.plan.end_ms + 1,
1738 ..piece.plan
1739 },
1740 &context,
1741 )
1742 .unwrap();
1743
1744 assert_eq!(
1745 exact.unwrap().raw_gemini_response,
1746 piece.raw_gemini_response
1747 );
1748 assert!(changed.is_none());
1749 drop(context);
1750 remove_database(&path);
1751 }
1752
1753 #[test]
1754 fn future_schema_versions_are_rejected() {
1755 let connection = Connection::open_in_memory().unwrap();
1756 connection
1757 .execute_batch("PRAGMA user_version = 11;")
1758 .unwrap();
1759 let error = apply_migrations(&connection).unwrap_err().to_string();
1760 assert!(error.contains("newer than supported"));
1761 }
1762
1763 #[test]
1764 fn interrupted_attempts_consume_the_fixed_budget() {
1765 let connection = database();
1766 let id = Uuid::new_v4();
1767 insert_recording(&connection, id, "transcribing");
1768 connection
1769 .execute(
1770 "UPDATE audio_recordings SET attempt_count=5 WHERE id=?1",
1771 [id.to_string()],
1772 )
1773 .unwrap();
1774 recover_interrupted_attempts(&connection).unwrap();
1775 let state: String = connection
1776 .query_row(
1777 "SELECT status FROM audio_recordings WHERE id=?1",
1778 [id.to_string()],
1779 |row| row.get(0),
1780 )
1781 .unwrap();
1782 assert_eq!(state, "failed");
1783 }
1784
1785 #[test]
1786 fn filenames_cannot_escape_the_persistence_root() {
1787 assert_eq!(safe_filename(Some("../../secret.wav")), "secret.wav");
1788 assert_eq!(safe_filename(Some("meeting note.wav")), "meeting_note.wav");
1789 assert_eq!(safe_filename(None), "audio.wav");
1790 }
1791}