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