Skip to main content

kcode_audio_ingress/
lib.rs

1//! Durable, automatic audio transcription and speaker correction.
2//!
3//! [`AudioIngress`] accepts owned WAV bytes, persists them before returning,
4//! and automatically transcribes, classifies, and reconciles them in the
5//! background.
6
7#![deny(missing_docs)]
8#![forbid(unsafe_code)]
9
10use std::{
11    collections::{HashMap, HashSet},
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};
20pub use kcode_audio_speaker_review::{
21    CLASSIFIER_MODEL, CLASSIFIER_PROMPT_VERSION, CLASSIFIER_PROVIDER, CLASSIFIER_SCHEMA_VERSION,
22    CandidateMapping, ChunkConfirmation, ConfirmationState, CorrectionChunk, CorrectionObservation,
23    CorrectionPacket, FeatureRow, LegacyReviewDisposition, ObservationConfirmation, ObservationKey,
24    ParsedChunk, ParsedSpeaker, SpeakerResolution, confirmation_matches, legacy_review_disposition,
25};
26use kcode_speaker_system::SpeechClassifier;
27use rusqlite::{Connection, OptionalExtension, params};
28use serde::Serialize;
29use serde_json::Value;
30use sha2::{Digest, Sha256};
31use tokio::io::AsyncWriteExt;
32use uuid::Uuid;
33
34use identity::ClassificationContext;
35pub use transcribe::{
36    AudioChunkCall, AudioChunkRequest, AudioTranscriber, IntelligenceError, IntelligenceFuture,
37    JobState, RECONCILIATION_MODEL, RECONCILIATION_REASONING, Step, StepError, StepState,
38    StepStatus, TRANSCRIPTION_MODEL, TextGenerationCall, TextGenerationRequest, TranscriptionJob,
39    TranscriptionStatus,
40};
41use transcribe::{ChunkPlan, PIECE_CACHE_REVISION, PieceCache, PieceSink};
42
43mod confirmation;
44mod identity;
45mod legacy_review;
46mod transcribe;
47mod wav_slice;
48
49const INITIAL_MIGRATION: &str = include_str!("../migrations/001_initial.sql");
50const RELEASE_DEFERRED_INGRESS_MIGRATION: &str =
51    include_str!("../migrations/002_release_deferred_ingress.sql");
52const TRANSCRIPTION_STATUS_MIGRATION: &str =
53    include_str!("../migrations/003_transcription_status.sql");
54const RETRY_ROUNDED_WAV_INTERVALS_MIGRATION: &str =
55    include_str!("../migrations/004_retry_rounded_wav_intervals.sql");
56const UNIFIED_INGRESS_QUEUE_MIGRATION: &str =
57    include_str!("../migrations/005_unified_ingress_queue.sql");
58const STANDALONE_LIBRARY_MIGRATION: &str = include_str!("../migrations/006_standalone_library.sql");
59const DURABLE_TRANSCRIPT_PIECES_MIGRATION: &str =
60    include_str!("../migrations/007_durable_transcript_pieces.sql");
61const UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION: &str =
62    include_str!("../migrations/008_unique_transcription_attempts.sql");
63const USAGE_USER_MIGRATION: &str = include_str!("../migrations/009_usage_user.sql");
64const SPEAKER_CORRECTION_PACKETS_MIGRATION: &str =
65    include_str!("../migrations/010_speaker_correction_packets.sql");
66const LEGACY_REVIEW_ARCHIVE_MIGRATION: &str =
67    include_str!("../migrations/011_legacy_review_archive.sql");
68
69const DATABASE_FILENAME: &str = "state.sqlite3";
70const ORIGINALS_DIRECTORY: &str = "originals";
71const FAILURE_LIMIT: i64 = 5;
72const RETRY_DELAY_SECONDS: i64 = 15;
73const LATEST_SCHEMA_VERSION: i64 = 11;
74
75const FRESH_SCHEMA: &str = r#"
76CREATE TABLE audio_recordings (
77    id TEXT PRIMARY KEY NOT NULL,
78    user_id TEXT NOT NULL CHECK(length(user_id) > 0),
79    sha256 TEXT NOT NULL UNIQUE CHECK(length(sha256) = 64),
80    original_filename TEXT NOT NULL,
81    content_type TEXT NOT NULL,
82    size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0),
83    source_created_at TEXT NOT NULL,
84    received_at TEXT NOT NULL,
85    updated_at TEXT NOT NULL,
86    original_relative_path TEXT NOT NULL,
87    status TEXT NOT NULL CHECK(status IN (
88        'uploaded', 'chunking', 'transcribing', 'reconciling',
89        'ready_for_ingress', 'ingressing', 'ingress_failed', 'complete', 'failed'
90    )),
91    gemini_model TEXT NOT NULL,
92    reconciliation_model TEXT NOT NULL,
93    reconciliation_reasoning TEXT NOT NULL,
94    final_transcript TEXT,
95    attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count >= 0),
96    next_attempt_at TEXT,
97    last_error TEXT,
98    transcription_status_json TEXT,
99    failure_retryable INTEGER NOT NULL DEFAULT 1
100        CHECK(failure_retryable IN (0, 1)),
101    correction_packet_json TEXT
102);
103
104CREATE INDEX audio_recordings_work_queue
105ON audio_recordings(status, next_attempt_at, received_at);
106
107CREATE TABLE audio_transcript_pieces (
108    recording_id TEXT NOT NULL REFERENCES audio_recordings(id) ON DELETE CASCADE,
109    attempt_id TEXT NOT NULL CHECK(length(attempt_id) > 0),
110    cache_revision TEXT NOT NULL CHECK(length(cache_revision) > 0),
111    piece_index INTEGER NOT NULL CHECK(piece_index >= 0),
112    piece_count INTEGER NOT NULL CHECK(piece_count > 0 AND piece_index < piece_count),
113    audio_start_ms INTEGER NOT NULL CHECK(audio_start_ms >= 0),
114    audio_end_ms INTEGER NOT NULL CHECK(audio_end_ms > audio_start_ms),
115    transcript_json TEXT NOT NULL,
116    raw_gemini_response TEXT NOT NULL,
117    parsed_json TEXT NOT NULL,
118    created_at TEXT NOT NULL,
119    PRIMARY KEY(recording_id, attempt_id, piece_index)
120);
121
122CREATE INDEX audio_transcript_pieces_cache_lookup
123ON audio_transcript_pieces(
124    recording_id,
125    cache_revision,
126    piece_index,
127    piece_count,
128    audio_start_ms,
129    audio_end_ms,
130    created_at
131);
132
133CREATE TABLE audio_legacy_review_archive (
134    recording_id TEXT PRIMARY KEY NOT NULL,
135    final_transcript TEXT NOT NULL CHECK(length(trim(final_transcript)) > 0),
136    correction_packet_json TEXT NOT NULL CHECK(length(correction_packet_json) > 0),
137    archived_at TEXT NOT NULL
138);
139
140PRAGMA user_version = 11;
141"#;
142
143/// Owned audio and its source metadata.
144#[derive(Clone, Debug)]
145pub struct AudioInput {
146    /// Stable application user identifier charged for provider calls.
147    pub user_id: String,
148    /// Complete WAV bytes.
149    pub bytes: Vec<u8>,
150    /// Instant at which recording began.
151    pub recorded_at: DateTime<Utc>,
152    /// Original leaf filename, when known.
153    pub original_filename: Option<String>,
154}
155
156/// Result of durably submitting audio.
157#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
158pub struct Submission {
159    /// Stable recording identifier.
160    pub recording_id: Uuid,
161    /// Whether the same audio bytes were already known.
162    pub deduplicated: bool,
163}
164
165/// One exact source interval encoded for human speaker review.
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct SpeakerReviewAudio {
168    /// Browser-playable media type.
169    pub content_type: &'static str,
170    /// Safe presentation filename.
171    pub filename: String,
172    /// Complete WAV interval bytes in the retained source format.
173    pub bytes: Vec<u8>,
174}
175
176/// Complete current library state.
177#[derive(Clone, Debug, Serialize)]
178pub struct Status {
179    /// Recordings ordered by recording time, newest first.
180    pub recordings: Vec<RecordingStatus>,
181}
182
183/// Current state, immutable identity, and optional completed correction packet.
184#[derive(Clone, Debug, Serialize)]
185pub struct RecordingStatus {
186    /// Stable recording identifier.
187    pub id: Uuid,
188    /// Stable application user identifier charged for provider calls.
189    pub user_id: String,
190    /// Lowercase SHA-256 digest of the original bytes.
191    pub sha256: String,
192    /// Sanitized original filename.
193    pub original_filename: String,
194    /// Original byte length.
195    pub size_bytes: u64,
196    /// Instant at which recording began.
197    pub recorded_at: DateTime<Utc>,
198    /// Instant at which AudioIngress accepted the recording.
199    pub received_at: DateTime<Utc>,
200    /// Gemini speaker-analysis model attribution.
201    pub transcription_model: String,
202    /// GPT parsing and reconciliation model attribution.
203    pub reconciliation_model: String,
204    /// GPT parsing and reconciliation reasoning attribution.
205    pub reconciliation_reasoning: String,
206    /// Current processing state.
207    pub state: RecordingState,
208    /// Durable transport-neutral packet, present after successful completion.
209    pub correction_packet: Option<CorrectionPacket>,
210}
211
212/// Automatic processing state of one recording.
213#[derive(Clone, Debug, Serialize)]
214#[serde(tag = "kind", rename_all = "snake_case")]
215pub enum RecordingState {
216    /// Persisted and waiting for automatic processing.
217    Queued,
218    /// An in-memory transcription attempt is active.
219    Processing {
220        /// One-based full-job attempt number.
221        attempt: u8,
222        /// Current dependency status without transcript or correction payloads.
223        progress: TranscriptionStatus,
224    },
225    /// Gemini transcript and speaker candidates await per-chunk human signoff.
226    AwaitingReview,
227    /// The canonical transcript and correction packet are durable.
228    Complete {
229        /// Canonical reconciled Markdown.
230        transcript: String,
231    },
232    /// Automatic processing stopped.
233    Failed {
234        /// Attempts used in the current manual-attempt budget.
235        attempts: u8,
236        /// Concise diagnostic.
237        error: String,
238        /// Whether another attempt can reasonably succeed.
239        retryable: bool,
240    },
241}
242
243/// Stable library error category.
244#[derive(Clone, Copy, Debug, Eq, PartialEq)]
245pub enum ErrorKind {
246    /// Supplied data is invalid.
247    InvalidInput,
248    /// The requested recording does not exist.
249    NotFound,
250    /// The requested transition is not valid in the current state.
251    Conflict,
252    /// Persistence or internal processing failed unexpectedly.
253    Internal,
254}
255
256/// Error returned by the AudioIngress API.
257#[derive(Debug)]
258pub struct Error {
259    kind: ErrorKind,
260    message: String,
261}
262
263impl Error {
264    fn invalid(message: impl Into<String>) -> Self {
265        Self {
266            kind: ErrorKind::InvalidInput,
267            message: message.into(),
268        }
269    }
270
271    fn not_found() -> Self {
272        Self {
273            kind: ErrorKind::NotFound,
274            message: "Audio recording not found.".into(),
275        }
276    }
277
278    fn conflict(message: impl Into<String>) -> Self {
279        Self {
280            kind: ErrorKind::Conflict,
281            message: message.into(),
282        }
283    }
284
285    fn internal(error: impl std::fmt::Display) -> Self {
286        tracing::error!(%error, "AudioIngress operation failed");
287        Self {
288            kind: ErrorKind::Internal,
289            message: "An unexpected AudioIngress error occurred.".into(),
290        }
291    }
292
293    /// Returns the stable error category.
294    pub fn kind(&self) -> ErrorKind {
295        self.kind
296    }
297}
298
299impl std::fmt::Display for Error {
300    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        formatter.write_str(&self.message)
302    }
303}
304
305impl std::error::Error for Error {}
306
307struct TemporaryUpload(PathBuf);
308
309impl Drop for TemporaryUpload {
310    fn drop(&mut self) {
311        let _ = fs::remove_file(&self.0);
312    }
313}
314
315struct Inner {
316    root: PathBuf,
317    db: Arc<Mutex<Connection>>,
318    classifier: Arc<SpeechClassifier>,
319    confirmations: confirmation::Lane,
320    transcriber: AudioTranscriber,
321    jobs: Mutex<HashMap<Uuid, TranscriptionJob>>,
322}
323
324/// Cloneable handle to durable, automatically processed audio.
325#[derive(Clone)]
326pub struct AudioIngress {
327    inner: Arc<Inner>,
328}
329
330impl AudioIngress {
331    /// Opens the owned persistence root with a shared classifier and starts automatic processing.
332    ///
333    /// Ingress state is stored at `<root>/state.sqlite3`, originals remain
334    /// below `<root>/originals/`, and classifier state is owned by the
335    /// caller-supplied shared classifier.
336    pub async fn open(
337        persistence_root: impl AsRef<Path>,
338        transcriber: AudioTranscriber,
339        classifier: Arc<SpeechClassifier>,
340    ) -> Result<Self, Error> {
341        let root = persistence_root.as_ref().to_path_buf();
342        ensure_private_directory(&root).map_err(Error::internal)?;
343        ensure_private_directory(&root.join(ORIGINALS_DIRECTORY)).map_err(Error::internal)?;
344
345        let connection = Connection::open(root.join(DATABASE_FILENAME)).map_err(Error::internal)?;
346        connection
347            .execute_batch(
348                "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA busy_timeout=15000;",
349            )
350            .map_err(Error::internal)?;
351        apply_migrations(&connection).map_err(Error::internal)?;
352        recover_interrupted_attempts(&connection).map_err(Error::internal)?;
353
354        let db = Arc::new(Mutex::new(connection));
355        let confirmations = confirmation::Lane::start(Arc::clone(&db), Arc::clone(&classifier))
356            .map_err(Error::internal)?;
357        let inner = Arc::new(Inner {
358            root,
359            db,
360            classifier,
361            confirmations,
362            transcriber,
363            jobs: Mutex::new(HashMap::new()),
364        });
365        tokio::spawn(worker_loop(Arc::downgrade(&inner)));
366        Ok(Self { inner })
367    }
368
369    /// Durably accepts complete WAV bytes and schedules automatic processing.
370    pub async fn submit(&self, input: AudioInput) -> Result<Submission, Error> {
371        if input.user_id.trim().is_empty() || input.user_id.chars().count() > 256 {
372            return Err(Error::invalid(
373                "User ID must contain between 1 and 256 characters.",
374            ));
375        }
376        if input.bytes.is_empty() {
377            return Err(Error::invalid("Audio bytes must not be empty."));
378        }
379        let size_bytes = i64::try_from(input.bytes.len())
380            .map_err(|_| Error::invalid("Audio is too large for this platform."))?;
381        let sha256 = format!("{:x}", Sha256::digest(&input.bytes));
382        if let Some(id) = self.recording_id_by_sha(&sha256)? {
383            return Ok(Submission {
384                recording_id: id,
385                deduplicated: true,
386            });
387        }
388
389        let upload_id = Uuid::new_v4();
390        let temporary = TemporaryUpload(self.inner.root.join(format!(".upload-{upload_id}.tmp")));
391        let mut file = tokio::fs::OpenOptions::new()
392            .write(true)
393            .create_new(true)
394            .open(&temporary.0)
395            .await
396            .map_err(Error::internal)?;
397        set_private_file(&temporary.0).map_err(Error::internal)?;
398        file.write_all(&input.bytes)
399            .await
400            .map_err(Error::internal)?;
401        file.sync_all().await.map_err(Error::internal)?;
402        drop(file);
403
404        let relative_path = format!("{ORIGINALS_DIRECTORY}/{sha256}.wav");
405        let final_path = self.inner.root.join(&relative_path);
406        if final_path.exists() {
407            tokio::fs::remove_file(&temporary.0)
408                .await
409                .map_err(Error::internal)?;
410        } else {
411            tokio::fs::rename(&temporary.0, &final_path)
412                .await
413                .map_err(Error::internal)?;
414        }
415        set_private_file(&final_path).map_err(Error::internal)?;
416        sync_file(&final_path).map_err(Error::internal)?;
417        sync_directory(final_path.parent().unwrap_or(&self.inner.root)).map_err(Error::internal)?;
418        sync_directory(&self.inner.root).map_err(Error::internal)?;
419
420        let id = Uuid::new_v4();
421        let now = Utc::now().to_rfc3339();
422        let filename = safe_filename(input.original_filename.as_deref());
423        let insert = {
424            let db = self.inner.db.lock().map_err(Error::internal)?;
425            db.execute(
426                "INSERT INTO audio_recordings(
427                    id,user_id,sha256,original_filename,content_type,size_bytes,
428                    source_created_at,received_at,updated_at,original_relative_path,
429                    status,gemini_model,reconciliation_model,reconciliation_reasoning
430                 ) VALUES(?1,?2,?3,?4,'audio/wav',?5,?6,?7,?7,?8,'uploaded',?9,?10,?11)",
431                params![
432                    id.to_string(),
433                    input.user_id,
434                    sha256,
435                    filename,
436                    size_bytes,
437                    input.recorded_at.to_rfc3339(),
438                    now,
439                    relative_path,
440                    TRANSCRIPTION_MODEL,
441                    RECONCILIATION_MODEL,
442                    RECONCILIATION_REASONING,
443                ],
444            )
445        };
446        if let Err(error) = insert {
447            if let Some(existing) = self.recording_id_by_sha(&sha256)? {
448                return Ok(Submission {
449                    recording_id: existing,
450                    deduplicated: true,
451                });
452            }
453            return Err(Error::internal(error));
454        }
455        tracing::info!(recording_id=%id, %sha256, bytes=size_bytes, "Durably accepted audio");
456        Ok(Submission {
457            recording_id: id,
458            deduplicated: false,
459        })
460    }
461
462    /// Returns all current recording states and completed correction packets.
463    pub fn status(&self) -> Result<Status, Error> {
464        let db = self.inner.db.lock().map_err(Error::internal)?;
465        let mut statement = db
466            .prepare(
467                "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,received_at,
468                        status,gemini_model,reconciliation_model,reconciliation_reasoning,
469                        attempt_count,last_error,failure_retryable,transcription_status_json,
470                        final_transcript,correction_packet_json
471                 FROM audio_recordings
472                 ORDER BY datetime(source_created_at) DESC,datetime(received_at) DESC,id DESC",
473            )
474            .map_err(Error::internal)?;
475        let recordings = statement
476            .query_map([], row_recording_status)
477            .map_err(Error::internal)?
478            .collect::<Result<Vec<_>, _>>()
479            .map_err(Error::internal)?;
480        Ok(Status { recordings })
481    }
482
483    /// Returns one correction packet with current read-only classifier evidence.
484    ///
485    /// The durable packet is read and the Audio Ingress persistence lock is
486    /// released before unsigned observations are scored. Refreshed guesses are
487    /// returned only in this projection and are never persisted or trained.
488    pub fn speaker_review_packet(&self, recording_id: Uuid) -> Result<CorrectionPacket, Error> {
489        let packet = {
490            let db = self.inner.db.lock().map_err(Error::internal)?;
491            db.query_row(
492                "SELECT correction_packet_json FROM audio_recordings WHERE id=?1",
493                [recording_id.to_string()],
494                |row| row.get::<_, Option<String>>(0),
495            )
496            .optional()
497            .map_err(Error::internal)?
498        };
499        let packet = packet
500            .ok_or_else(Error::not_found)?
501            .ok_or_else(|| Error::conflict("Speaker review is not ready."))
502            .and_then(|value| StoredCorrectionPacket::decode(&value).map_err(Error::internal))?
503            .packet;
504        kcode_audio_speaker_candidates::refresh_unconfirmed(&self.inner.classifier, &packet)
505            .map_err(Error::internal)
506    }
507
508    /// Reads the exact retained source interval for one correction-packet chunk.
509    pub fn speaker_review_audio(
510        &self,
511        recording_id: Uuid,
512        chunk_index: usize,
513    ) -> Result<SpeakerReviewAudio, Error> {
514        let stored = {
515            let db = self.inner.db.lock().map_err(Error::internal)?;
516            db.query_row(
517                "SELECT original_relative_path,correction_packet_json
518                 FROM audio_recordings WHERE id=?1",
519                [recording_id.to_string()],
520                |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
521            )
522            .optional()
523            .map_err(Error::internal)?
524        };
525        let Some((relative_path, packet_json)) = stored else {
526            return Err(Error::not_found());
527        };
528        let packet = packet_json
529            .ok_or_else(|| Error::conflict("Speaker review audio is not ready."))
530            .and_then(|value| StoredCorrectionPacket::decode(&value).map_err(Error::internal))?;
531        let chunk = packet
532            .packet
533            .chunks
534            .iter()
535            .find(|chunk| chunk.chunk_index == chunk_index)
536            .ok_or_else(|| Error::invalid("Speaker review chunk does not exist."))?;
537        let original =
538            fs::File::open(self.inner.root.join(relative_path)).map_err(Error::internal)?;
539        let bytes = wav_slice::interval(original, chunk.audio_start_ms, chunk.audio_end_ms)
540            .map_err(Error::internal)?;
541        Ok(SpeakerReviewAudio {
542            content_type: "audio/wav",
543            filename: format!(
544                "speaker-review-{}-chunk-{}.wav",
545                recording_id,
546                chunk_index + 1
547            ),
548            bytes,
549        })
550    }
551
552    /// Gives a failed recording a fresh five-attempt processing budget.
553    pub fn retry(&self, recording_id: Uuid) -> Result<(), Error> {
554        let db = self.inner.db.lock().map_err(Error::internal)?;
555        let changed = db
556            .execute(
557                "UPDATE audio_recordings
558                 SET status='uploaded',attempt_count=0,next_attempt_at=NULL,last_error=NULL,
559                     transcription_status_json=NULL,failure_retryable=1,updated_at=?1
560                 WHERE id=?2 AND status='failed'",
561                params![Utc::now().to_rfc3339(), recording_id.to_string()],
562            )
563            .map_err(Error::internal)?;
564        if changed == 1 {
565            return Ok(());
566        }
567        let exists = db
568            .query_row(
569                "SELECT 1 FROM audio_recordings WHERE id=?1",
570                [recording_id.to_string()],
571                |row| row.get::<_, i64>(0),
572            )
573            .optional()
574            .map_err(Error::internal)?
575            .is_some();
576        if exists {
577            Err(Error::conflict("Only a failed recording can be retried."))
578        } else {
579            Err(Error::not_found())
580        }
581    }
582
583    /// Returns all distinct known speaker names in deterministic order.
584    pub fn known_speakers(&self) -> Result<Vec<String>, Error> {
585        self.inner
586            .classifier
587            .known_speakers()
588            .map_err(Error::internal)
589    }
590
591    /// Resolves one finalized recording whose obsolete packet was never signed.
592    ///
593    /// The caller owns the downstream-ingress decision. Reprocessing archives
594    /// the exact old transcript and packet before it clears the active result
595    /// and queues the retained WAV. Completing preserves both payloads and
596    /// marks the recording as an immutable legacy result.
597    pub fn resolve_legacy_review(
598        &self,
599        recording_id: Uuid,
600        disposition: LegacyReviewDisposition,
601    ) -> Result<(), Error> {
602        let mut db = self.inner.db.lock().map_err(Error::internal)?;
603        match legacy_review::resolve(&mut db, recording_id, disposition, Utc::now())
604            .map_err(Error::internal)?
605        {
606            legacy_review::Outcome::Applied | legacy_review::Outcome::Unchanged => Ok(()),
607            legacy_review::Outcome::Missing => Err(Error::not_found()),
608            legacy_review::Outcome::Ineligible => Err(Error::conflict(
609                "Recording is not an unresolved finalized legacy review.",
610            )),
611        }
612    }
613
614    /// Applies exact known-or-unknown resolutions and signs off one review chunk.
615    ///
616    /// The confirmation must cover every deterministic observation key in the
617    /// packet exactly once. Classifier updates are idempotent. The corrected
618    /// packet is committed before this method returns and is also visible
619    /// through [`AudioIngress::status`].
620    pub fn confirm_speakers(
621        &self,
622        confirmation: ChunkConfirmation,
623    ) -> Result<CorrectionPacket, Error> {
624        self.inner.confirmations.confirm(confirmation)
625    }
626
627    fn recording_id_by_sha(&self, sha256: &str) -> Result<Option<Uuid>, Error> {
628        let db = self.inner.db.lock().map_err(Error::internal)?;
629        db.query_row(
630            "SELECT id FROM audio_recordings WHERE sha256=?1",
631            [sha256],
632            |row| row.get::<_, String>(0),
633        )
634        .optional()
635        .map_err(Error::internal)?
636        .map(|id| Uuid::parse_str(&id).map_err(Error::internal))
637        .transpose()
638    }
639}
640
641fn row_recording_status(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecordingStatus> {
642    let id: String = row.get(0)?;
643    let recorded_at: String = row.get(5)?;
644    let received_at: String = row.get(6)?;
645    let durable_status: String = row.get(7)?;
646    let attempts: i64 = row.get(11)?;
647    let last_error: Option<String> = row.get(12)?;
648    let retryable: i64 = row.get(13)?;
649    let progress_json: Option<String> = row.get(14)?;
650    let transcript: Option<String> = row.get(15)?;
651    let correction_packet_json: Option<String> = row.get(16)?;
652    let parse_time = |index, value: &str| {
653        DateTime::parse_from_rfc3339(value)
654            .map(|value| value.with_timezone(&Utc))
655            .map_err(|error| {
656                rusqlite::Error::FromSqlConversionFailure(
657                    index,
658                    rusqlite::types::Type::Text,
659                    Box::new(error),
660                )
661            })
662    };
663    let state = match durable_status.as_str() {
664        "uploaded" => RecordingState::Queued,
665        "chunking" | "transcribing" | "reconciling" => {
666            let progress = progress_json
667                .as_deref()
668                .map(serde_json::from_str)
669                .transpose()
670                .map_err(|error| {
671                    rusqlite::Error::FromSqlConversionFailure(
672                        14,
673                        rusqlite::types::Type::Text,
674                        Box::new(error),
675                    )
676                })?
677                .unwrap_or_else(initial_progress);
678            RecordingState::Processing {
679                attempt: attempts.clamp(0, i64::from(u8::MAX)) as u8,
680                progress: without_results(progress),
681            }
682        }
683        "ready_for_ingress" | "ingressing" | "ingress_failed" | "complete" => {
684            match transcript.filter(|value| !value.trim().is_empty()) {
685                Some(transcript) => RecordingState::Complete { transcript },
686                None => RecordingState::AwaitingReview,
687            }
688        }
689        "failed" => RecordingState::Failed {
690            attempts: attempts.clamp(0, i64::from(u8::MAX)) as u8,
691            error: last_error.unwrap_or_else(|| "Audio processing failed.".into()),
692            retryable: retryable != 0,
693        },
694        other => {
695            return Err(rusqlite::Error::FromSqlConversionFailure(
696                7,
697                rusqlite::types::Type::Text,
698                format!("unknown audio status {other:?}").into(),
699            ));
700        }
701    };
702    let correction_packet = if durable_status == "complete" {
703        None
704    } else {
705        correction_packet_json
706            .as_deref()
707            .map(StoredCorrectionPacket::decode)
708            .transpose()
709            .map_err(|error| {
710                rusqlite::Error::FromSqlConversionFailure(
711                    16,
712                    rusqlite::types::Type::Text,
713                    Box::new(error),
714                )
715            })?
716            .map(|stored| stored.packet)
717    };
718    Ok(RecordingStatus {
719        id: Uuid::parse_str(&id).map_err(|error| {
720            rusqlite::Error::FromSqlConversionFailure(
721                0,
722                rusqlite::types::Type::Text,
723                Box::new(error),
724            )
725        })?,
726        user_id: row.get(1)?,
727        sha256: row.get(2)?,
728        original_filename: row.get(3)?,
729        size_bytes: u64::try_from(row.get::<_, i64>(4)?).map_err(|error| {
730            rusqlite::Error::FromSqlConversionFailure(
731                4,
732                rusqlite::types::Type::Integer,
733                Box::new(error),
734            )
735        })?,
736        recorded_at: parse_time(5, &recorded_at)?,
737        received_at: parse_time(6, &received_at)?,
738        transcription_model: row.get(8)?,
739        reconciliation_model: row.get(9)?,
740        reconciliation_reasoning: row.get(10)?,
741        state,
742        correction_packet,
743    })
744}
745
746#[derive(Clone, Debug)]
747struct LegacyFeatureRow {
748    chunk_position: usize,
749    speaker_position: usize,
750    value: Value,
751}
752
753#[derive(Clone, Debug)]
754struct StoredCorrectionPacket {
755    packet: CorrectionPacket,
756    legacy_feature_rows: Vec<LegacyFeatureRow>,
757}
758
759impl StoredCorrectionPacket {
760    fn decode(serialized: &str) -> serde_json::Result<Self> {
761        let mut value: Value = serde_json::from_str(serialized)?;
762        let confirmed = matches!(
763            value.get("confirmation_state").and_then(Value::as_str),
764            Some("confirmed" | "automatically_trained")
765        );
766        if let Some(object) = value.as_object_mut() {
767            object.remove("clean");
768        }
769        let mut legacy_feature_rows = Vec::new();
770        if let Some(chunks) = value.get_mut("chunks").and_then(Value::as_array_mut) {
771            for (chunk_position, chunk) in chunks.iter_mut().enumerate() {
772                if let Some(object) = chunk.as_object_mut() {
773                    object.remove("clean");
774                    object
775                        .entry("signed_off")
776                        .or_insert_with(|| Value::Bool(confirmed));
777                }
778                if let Some(observations) =
779                    chunk.get_mut("observations").and_then(Value::as_array_mut)
780                {
781                    for observation in observations {
782                        let Some(object) = observation.as_object_mut() else {
783                            continue;
784                        };
785                        if !object.contains_key("resolution") {
786                            let name = object
787                                .get("confirmed_full_name")
788                                .or_else(|| object.get("identified_full_name"))
789                                .and_then(Value::as_str)
790                                .filter(|_| confirmed)
791                                .map(str::to_owned);
792                            object.insert(
793                                "resolution".into(),
794                                name.map_or(Value::Null, |full_name| {
795                                    serde_json::json!({"kind":"known","full_name":full_name})
796                                }),
797                            );
798                        }
799                        object.remove("confirmed_full_name");
800                        object.remove("identified_full_name");
801                        if let Some(candidate) =
802                            object.get_mut("candidate").and_then(Value::as_object_mut)
803                        {
804                            if !candidate.contains_key("score") {
805                                let score = candidate
806                                    .remove("cost")
807                                    .and_then(|value| value.as_f64())
808                                    .map(|cost| Value::from(-cost));
809                                if let Some(score) = score {
810                                    candidate.insert("score".into(), score);
811                                }
812                            }
813                            if !candidate.contains_key("runner_up_score") {
814                                let runner = candidate
815                                    .remove("runner_up_cost")
816                                    .and_then(|value| value.as_f64())
817                                    .map(|cost| Value::from(-cost))
818                                    .unwrap_or(Value::Null);
819                                candidate.insert("runner_up_score".into(), runner);
820                            }
821                            candidate.remove("confidence");
822                            candidate.remove("runner_up_full_name");
823                            candidate.remove("background_population_cost");
824                        }
825                    }
826                }
827                if let Some(speakers) = chunk
828                    .pointer_mut("/parsed/speakers")
829                    .and_then(Value::as_array_mut)
830                {
831                    for (speaker_position, speaker) in speakers.iter_mut().enumerate() {
832                        let Some(feature_row) = speaker.get_mut("feature_row") else {
833                            continue;
834                        };
835                        if is_legacy_feature_row(feature_row) {
836                            legacy_feature_rows.push(LegacyFeatureRow {
837                                chunk_position,
838                                speaker_position,
839                                value: feature_row.take(),
840                            });
841                        }
842                    }
843                }
844            }
845        }
846        let packet = serde_json::from_value(value)?;
847        Ok(Self {
848            packet,
849            legacy_feature_rows,
850        })
851    }
852
853    fn encode(&self) -> serde_json::Result<String> {
854        let mut value = serde_json::to_value(&self.packet)?;
855        for legacy in &self.legacy_feature_rows {
856            let feature_row = value
857                .get_mut("chunks")
858                .and_then(Value::as_array_mut)
859                .and_then(|chunks| chunks.get_mut(legacy.chunk_position))
860                .and_then(|chunk| chunk.pointer_mut("/parsed/speakers"))
861                .and_then(Value::as_array_mut)
862                .and_then(|speakers| speakers.get_mut(legacy.speaker_position))
863                .and_then(|speaker| speaker.get_mut("feature_row"))
864                .expect("decoded correction packet retains its speaker positions");
865            *feature_row = legacy.value.clone();
866        }
867        serde_json::to_string(&value)
868    }
869
870    fn legacy_observation_keys(&self) -> anyhow::Result<HashSet<ObservationKey>> {
871        let mut keys = HashSet::new();
872        for legacy in &self.legacy_feature_rows {
873            let chunk = self
874                .packet
875                .chunks
876                .get(legacy.chunk_position)
877                .context("legacy feature row is outside its correction packet")?;
878            let speaker = chunk
879                .parsed
880                .speakers
881                .get(legacy.speaker_position)
882                .context("legacy feature row is outside its parsed speakers")?;
883            let observation = chunk
884                .observations
885                .iter()
886                .find(|observation| {
887                    observation.speaker_ordinal as usize == legacy.speaker_position
888                        && observation.local_label == speaker.local_label
889                })
890                .context("legacy feature row has no matching correction observation")?;
891            ensure!(
892                keys.insert(observation.observation_key.clone()),
893                "legacy correction packet repeats an observation key"
894            );
895        }
896        Ok(keys)
897    }
898}
899
900fn is_legacy_feature_row(value: &Value) -> bool {
901    const FIELDS: [&str; 24] = [
902        "accent_variety",
903        "articulation_rate_syllables_per_second",
904        "breathiness",
905        "cefr",
906        "consonant_cluster_reduction_percent",
907        "creaky_phonation_percent",
908        "f0_pitch_span_semitones",
909        "filled_pauses_per_100_words",
910        "foreign_accentedness",
911        "formant_dispersion_hz",
912        "hypernasality",
913        "lateral_realization",
914        "lexical_stress_accuracy_percent",
915        "median_f0_hz",
916        "monophthongization_percent",
917        "npvi_v",
918        "perceived_age",
919        "rhotic_realization",
920        "roughness",
921        "s_realization",
922        "unstressed_vowel_reduction_percent",
923        "vai",
924        "vocal_gender_presentation",
925        "word_initial_stressed_prevocalic_t_vot_ms",
926    ];
927    value.as_object().is_some_and(|object| {
928        object.len() == FIELDS.len() && FIELDS.iter().all(|field| object.contains_key(*field))
929    })
930}
931
932async fn worker_loop(inner: Weak<Inner>) {
933    loop {
934        let Some(inner) = inner.upgrade() else {
935            return;
936        };
937        let worked = match process_next_recording(&inner).await {
938            Ok(worked) => worked,
939            Err(error) => {
940                tracing::error!(error=%error, "AudioIngress worker iteration failed");
941                false
942            }
943        };
944        drop(inner);
945        tokio::time::sleep(if worked {
946            Duration::from_millis(100)
947        } else {
948            Duration::from_secs(5)
949        })
950        .await;
951    }
952}
953
954#[derive(Debug)]
955struct WorkRecording {
956    id: Uuid,
957    user_id: String,
958    sha256: String,
959    original_filename: String,
960    size_bytes: u64,
961    recorded_at: DateTime<Utc>,
962    original_relative_path: String,
963    attempt_count: i64,
964    correction_packet_json: Option<String>,
965}
966
967async fn process_next_recording(inner: &Inner) -> anyhow::Result<bool> {
968    let recording = {
969        let db = inner
970            .db
971            .lock()
972            .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
973        fetch_work_recording(&db)?
974    };
975    let Some(recording) = recording else {
976        return Ok(false);
977    };
978    poll_transcription(inner, recording).await?;
979    Ok(true)
980}
981
982fn fetch_work_recording(db: &Connection) -> anyhow::Result<Option<WorkRecording>> {
983    db.query_row(
984        "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,
985                original_relative_path,attempt_count,correction_packet_json
986         FROM audio_recordings
987         WHERE status IN ('uploaded','chunking','transcribing','reconciling')
988           AND (next_attempt_at IS NULL OR datetime(next_attempt_at)<=datetime('now'))
989         ORDER BY datetime(received_at),id
990         LIMIT 1",
991        [],
992        |row| {
993            Ok((
994                row.get::<_, String>(0)?,
995                row.get::<_, String>(1)?,
996                row.get::<_, String>(2)?,
997                row.get::<_, String>(3)?,
998                row.get::<_, i64>(4)?,
999                row.get::<_, String>(5)?,
1000                row.get::<_, String>(6)?,
1001                row.get::<_, i64>(7)?,
1002                row.get::<_, Option<String>>(8)?,
1003            ))
1004        },
1005    )
1006    .optional()?
1007    .map(
1008        |(
1009            id,
1010            user_id,
1011            sha256,
1012            original_filename,
1013            size_bytes,
1014            recorded_at,
1015            original_relative_path,
1016            attempt_count,
1017            correction_packet_json,
1018        )| {
1019            Ok(WorkRecording {
1020                id: Uuid::parse_str(&id)?,
1021                user_id,
1022                sha256,
1023                original_filename,
1024                size_bytes: u64::try_from(size_bytes).context("stored audio size is negative")?,
1025                recorded_at: DateTime::parse_from_rfc3339(&recorded_at)
1026                    .context("stored recording time is invalid")?
1027                    .with_timezone(&Utc),
1028                original_relative_path,
1029                attempt_count,
1030                correction_packet_json,
1031            })
1032        },
1033    )
1034    .transpose()
1035}
1036
1037async fn poll_transcription(inner: &Inner, mut recording: WorkRecording) -> anyhow::Result<()> {
1038    let existing = inner
1039        .jobs
1040        .lock()
1041        .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1042        .get(&recording.id)
1043        .cloned();
1044    let job = if let Some(job) = existing {
1045        job
1046    } else {
1047        if recording.attempt_count >= FAILURE_LIMIT {
1048            mark_failed(
1049                inner,
1050                recording.id,
1051                recording.attempt_count,
1052                true,
1053                "Audio transcription exhausted its five automatic attempts.",
1054                None,
1055            )?;
1056            return Ok(());
1057        }
1058        let final_packet = recording
1059            .correction_packet_json
1060            .as_deref()
1061            .map(StoredCorrectionPacket::decode)
1062            .transpose()?
1063            .map(|stored| stored.packet)
1064            .filter(|packet| packet.confirmation_state == ConfirmationState::Confirmed);
1065        if let Some(packet) = final_packet {
1066            recording.attempt_count += 1;
1067            {
1068                let db = inner
1069                    .db
1070                    .lock()
1071                    .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1072                db.execute(
1073                    "UPDATE audio_recordings
1074                     SET status='reconciling',attempt_count=?1,next_attempt_at=NULL,
1075                         last_error=NULL,failure_retryable=1,updated_at=?2
1076                     WHERE id=?3",
1077                    params![
1078                        recording.attempt_count,
1079                        Utc::now().to_rfc3339(),
1080                        recording.id.to_string()
1081                    ],
1082                )?;
1083            }
1084            let job = inner
1085                .transcriber
1086                .finalize_durably(recording.user_id.clone(), packet);
1087            inner
1088                .jobs
1089                .lock()
1090                .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1091                .insert(recording.id, job.clone());
1092            job
1093        } else {
1094            let source = inner.root.join(&recording.original_relative_path);
1095            let audio = tokio::fs::read(&source)
1096                .await
1097                .with_context(|| format!("reading retained audio {}", source.display()))?;
1098            recording.attempt_count += 1;
1099            {
1100                let db = inner
1101                    .db
1102                    .lock()
1103                    .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1104                db.execute(
1105                    "UPDATE audio_recordings
1106                 SET status='chunking',attempt_count=?1,next_attempt_at=NULL,last_error=NULL,
1107                     failure_retryable=1,updated_at=?2
1108                 WHERE id=?3",
1109                    params![
1110                        recording.attempt_count,
1111                        Utc::now().to_rfc3339(),
1112                        recording.id.to_string()
1113                    ],
1114                )?;
1115            }
1116
1117            let classification = ClassificationContext {
1118                recording_id: recording.id,
1119                user_id: recording.user_id.clone(),
1120                sha256: recording.sha256.clone(),
1121                original_filename: recording.original_filename.clone(),
1122                size_bytes: recording.size_bytes,
1123                recorded_at: recording.recorded_at,
1124                classifier: inner.classifier.clone(),
1125            };
1126
1127            let cache_db = inner.db.clone();
1128            let cache_recording_id = recording.id;
1129            let piece_cache: PieceCache = Arc::new(move |plan| {
1130                load_cached_transcript_piece(&cache_db, cache_recording_id, plan)
1131            });
1132
1133            let sink_db = inner.db.clone();
1134            let sink_recording_id = recording.id;
1135            let attempt_id = Uuid::new_v4().to_string();
1136            let piece_sink: PieceSink = Arc::new(move |plan, raw| {
1137                persist_transcript_piece(&sink_db, sink_recording_id, &attempt_id, plan, raw)
1138            });
1139
1140            let job = inner.transcriber.transcribe_durably(
1141                recording.user_id.clone(),
1142                audio,
1143                piece_cache,
1144                piece_sink,
1145                classification,
1146            );
1147            inner
1148                .jobs
1149                .lock()
1150                .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1151                .insert(recording.id, job.clone());
1152            job
1153        }
1154    };
1155
1156    let snapshot = job.status();
1157    persist_progress(inner, recording.id, &snapshot)?;
1158    match snapshot.state {
1159        JobState::Queued | JobState::Running => Ok(()),
1160        JobState::Completed => {
1161            let progress = serde_json::to_string(&without_results(snapshot.clone()))?;
1162            let db = inner
1163                .db
1164                .lock()
1165                .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1166            if let Some(transcript) = snapshot.transcript.as_deref() {
1167                ensure!(
1168                    !transcript.trim().is_empty(),
1169                    "completed transcript is empty"
1170                );
1171                db.execute(
1172                    "UPDATE audio_recordings
1173                     SET status='ready_for_ingress',final_transcript=?1,
1174                         transcription_status_json=?2,next_attempt_at=NULL,last_error=NULL,
1175                         failure_retryable=1,updated_at=?3 WHERE id=?4",
1176                    params![
1177                        transcript.trim(),
1178                        progress,
1179                        Utc::now().to_rfc3339(),
1180                        recording.id.to_string()
1181                    ],
1182                )?;
1183                tracing::info!(recording_id=%recording.id, "Final audio transcript completed");
1184            } else {
1185                let packet = snapshot
1186                    .correction_packet
1187                    .as_ref()
1188                    .context("completed analysis omitted its correction packet")?;
1189                ensure!(
1190                    packet.recording_id == recording.id,
1191                    "correction packet belongs to another recording"
1192                );
1193                db.execute(
1194                    "UPDATE audio_recordings
1195                     SET status='ready_for_ingress',final_transcript=NULL,
1196                         correction_packet_json=?1,transcription_status_json=?2,
1197                         next_attempt_at=NULL,last_error=NULL,failure_retryable=1,updated_at=?3
1198                     WHERE id=?4",
1199                    params![
1200                        serde_json::to_string(packet)?,
1201                        progress,
1202                        Utc::now().to_rfc3339(),
1203                        recording.id.to_string()
1204                    ],
1205                )?;
1206                tracing::info!(recording_id=%recording.id, "Audio chunks are ready for speaker review");
1207            }
1208            remove_job(inner, recording.id)?;
1209            Ok(())
1210        }
1211        JobState::Failed => {
1212            let error = snapshot
1213                .steps
1214                .iter()
1215                .find(|step| step.state == StepState::Failed)
1216                .and_then(|step| step.error.as_ref());
1217            let message = error
1218                .map(|error| error.message.clone())
1219                .unwrap_or_else(|| "Audio transcription failed without detail.".into());
1220            let retryable = error.is_none_or(|error| error.retryable);
1221            record_attempt_failure(
1222                inner,
1223                recording.id,
1224                recording.attempt_count,
1225                retryable,
1226                &message,
1227                Some(snapshot),
1228            )?;
1229            remove_job(inner, recording.id)
1230        }
1231    }
1232}
1233
1234fn record_attempt_failure(
1235    inner: &Inner,
1236    id: Uuid,
1237    attempts: i64,
1238    retryable: bool,
1239    message: &str,
1240    progress: Option<TranscriptionStatus>,
1241) -> anyhow::Result<()> {
1242    if !retryable || attempts >= FAILURE_LIMIT {
1243        return mark_failed(inner, id, attempts, retryable, message, progress);
1244    }
1245    let db = inner
1246        .db
1247        .lock()
1248        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1249    db.execute(
1250        "UPDATE audio_recordings
1251         SET status=CASE
1252                 WHEN status='reconciling' AND correction_packet_json IS NOT NULL
1253                      AND final_transcript IS NULL THEN 'reconciling'
1254                 ELSE 'uploaded'
1255             END,
1256             next_attempt_at=?1,last_error=?2,failure_retryable=1,
1257             transcription_status_json=?3,updated_at=?4
1258         WHERE id=?5",
1259        params![
1260            (Utc::now() + ChronoDuration::seconds(RETRY_DELAY_SECONDS)).to_rfc3339(),
1261            concise(message, 2_000),
1262            progress
1263                .map(without_results)
1264                .map(|progress| serde_json::to_string(&progress))
1265                .transpose()?,
1266            Utc::now().to_rfc3339(),
1267            id.to_string()
1268        ],
1269    )?;
1270    tracing::warn!(recording_id=%id, attempt=attempts, "Audio transcription will retry");
1271    Ok(())
1272}
1273
1274fn mark_failed(
1275    inner: &Inner,
1276    id: Uuid,
1277    attempts: i64,
1278    retryable: bool,
1279    message: &str,
1280    progress: Option<TranscriptionStatus>,
1281) -> anyhow::Result<()> {
1282    let db = inner
1283        .db
1284        .lock()
1285        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1286    db.execute(
1287        "UPDATE audio_recordings
1288         SET status='failed',attempt_count=?1,next_attempt_at=NULL,last_error=?2,
1289             failure_retryable=?3,transcription_status_json=?4,updated_at=?5
1290         WHERE id=?6",
1291        params![
1292            attempts,
1293            concise(message, 2_000),
1294            i64::from(retryable),
1295            progress
1296                .map(without_results)
1297                .map(|progress| serde_json::to_string(&progress))
1298                .transpose()?,
1299            Utc::now().to_rfc3339(),
1300            id.to_string()
1301        ],
1302    )?;
1303    tracing::error!(recording_id=%id, attempts, retryable, "Audio transcription stopped");
1304    Ok(())
1305}
1306
1307fn persist_progress(inner: &Inner, id: Uuid, snapshot: &TranscriptionStatus) -> anyhow::Result<()> {
1308    let durable_status = transcription_stage(snapshot);
1309    let serialized = serde_json::to_string(&without_results(snapshot.clone()))?;
1310    let db = inner
1311        .db
1312        .lock()
1313        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1314    db.execute(
1315        "UPDATE audio_recordings
1316         SET status=?1,transcription_status_json=?2,updated_at=?3
1317         WHERE id=?4 AND (status<>?1 OR COALESCE(transcription_status_json,'')<>?2)",
1318        params![
1319            durable_status,
1320            serialized,
1321            Utc::now().to_rfc3339(),
1322            id.to_string()
1323        ],
1324    )?;
1325    Ok(())
1326}
1327
1328fn load_cached_transcript_piece(
1329    db: &Mutex<Connection>,
1330    recording_id: Uuid,
1331    plan: ChunkPlan,
1332) -> anyhow::Result<Option<String>> {
1333    let piece_index = i64::try_from(plan.index).context("piece index exceeds SQLite limits")?;
1334    let piece_count = i64::try_from(plan.total).context("piece count exceeds SQLite limits")?;
1335    let audio_start_ms =
1336        i64::try_from(plan.start_ms).context("piece start exceeds SQLite limits")?;
1337    let audio_end_ms = i64::try_from(plan.end_ms).context("piece end exceeds SQLite limits")?;
1338    let stored = {
1339        let db = db
1340            .lock()
1341            .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1342        db.query_row(
1343            "SELECT raw_gemini_response
1344             FROM audio_transcript_pieces
1345             WHERE recording_id=?1
1346               AND cache_revision=?2
1347               AND piece_index=?3
1348               AND piece_count=?4
1349               AND audio_start_ms=?5
1350               AND audio_end_ms=?6
1351             ORDER BY datetime(created_at) DESC,attempt_id DESC
1352             LIMIT 1",
1353            params![
1354                recording_id.to_string(),
1355                PIECE_CACHE_REVISION,
1356                piece_index,
1357                piece_count,
1358                audio_start_ms,
1359                audio_end_ms,
1360            ],
1361            |row| row.get::<_, String>(0),
1362        )
1363        .optional()?
1364    };
1365    let Some(raw_gemini_response) = stored else {
1366        return Ok(None);
1367    };
1368    ensure!(
1369        !raw_gemini_response.trim().is_empty(),
1370        "cached piece omitted its raw Gemini response"
1371    );
1372    Ok(Some(raw_gemini_response))
1373}
1374
1375fn persist_transcript_piece(
1376    db: &Mutex<Connection>,
1377    recording_id: Uuid,
1378    attempt_id: &str,
1379    plan: ChunkPlan,
1380    raw_gemini_response: &str,
1381) -> anyhow::Result<()> {
1382    ensure!(!attempt_id.is_empty(), "piece attempt identity is empty");
1383    let piece_index = i64::try_from(plan.index).context("piece index exceeds SQLite limits")?;
1384    let piece_count = i64::try_from(plan.total).context("piece count exceeds SQLite limits")?;
1385    let audio_start_ms =
1386        i64::try_from(plan.start_ms).context("piece start exceeds SQLite limits")?;
1387    let audio_end_ms = i64::try_from(plan.end_ms).context("piece end exceeds SQLite limits")?;
1388    let db = db
1389        .lock()
1390        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1391    db.execute(
1392        "INSERT INTO audio_transcript_pieces(
1393            recording_id,attempt_id,cache_revision,piece_index,piece_count,
1394            audio_start_ms,audio_end_ms,transcript_json,raw_gemini_response,
1395            parsed_json,created_at
1396         ) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?8,?10)
1397         ON CONFLICT(recording_id,attempt_id,piece_index) DO UPDATE SET
1398            cache_revision=excluded.cache_revision,
1399            piece_count=excluded.piece_count,
1400            audio_start_ms=excluded.audio_start_ms,
1401            audio_end_ms=excluded.audio_end_ms,
1402            transcript_json=excluded.transcript_json,
1403            raw_gemini_response=excluded.raw_gemini_response,
1404            parsed_json=excluded.parsed_json",
1405        params![
1406            recording_id.to_string(),
1407            attempt_id,
1408            PIECE_CACHE_REVISION,
1409            piece_index,
1410            piece_count,
1411            audio_start_ms,
1412            audio_end_ms,
1413            raw_gemini_response,
1414            raw_gemini_response,
1415            Utc::now().to_rfc3339(),
1416        ],
1417    )?;
1418    Ok(())
1419}
1420
1421fn remove_job(inner: &Inner, id: Uuid) -> anyhow::Result<()> {
1422    inner
1423        .jobs
1424        .lock()
1425        .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1426        .remove(&id);
1427    Ok(())
1428}
1429
1430fn transcription_stage(snapshot: &TranscriptionStatus) -> &'static str {
1431    if snapshot.steps.len() == 1 && snapshot.steps[0].step == Step::ReconcileTranscript {
1432        return "reconciling";
1433    }
1434    let plan_complete = snapshot
1435        .steps
1436        .iter()
1437        .any(|entry| entry.step == Step::PlanChunks && entry.state == StepState::Completed);
1438    if !plan_complete {
1439        return "chunking";
1440    }
1441    let chunks_complete = snapshot
1442        .steps
1443        .iter()
1444        .filter(|entry| matches!(entry.step, Step::TranscribeChunk { .. }))
1445        .all(|entry| entry.state == StepState::Completed);
1446    if chunks_complete {
1447        "reconciling"
1448    } else {
1449        "transcribing"
1450    }
1451}
1452
1453fn without_results(mut status: TranscriptionStatus) -> TranscriptionStatus {
1454    status.transcript = None;
1455    status.correction_packet = None;
1456    status
1457}
1458
1459fn initial_progress() -> TranscriptionStatus {
1460    TranscriptionStatus {
1461        state: JobState::Queued,
1462        steps: Vec::new(),
1463        transcript: None,
1464        correction_packet: None,
1465    }
1466}
1467
1468fn recover_interrupted_attempts(connection: &Connection) -> rusqlite::Result<()> {
1469    connection.execute(
1470        "UPDATE audio_recordings
1471         SET status=CASE
1472                 WHEN status='reconciling' AND correction_packet_json IS NOT NULL
1473                      AND final_transcript IS NULL AND attempt_count<?1 THEN 'reconciling'
1474                 WHEN attempt_count>=?1 THEN 'failed'
1475                 ELSE 'uploaded'
1476             END,
1477             next_attempt_at=NULL,
1478             last_error=CASE WHEN attempt_count>=?1
1479                 THEN 'Audio transcription stopped after its fifth attempt was interrupted.'
1480                 ELSE 'Audio transcription was interrupted and will restart automatically.'
1481             END,
1482             failure_retryable=1,
1483             updated_at=?2
1484         WHERE status IN ('chunking','transcribing','reconciling')",
1485        params![FAILURE_LIMIT, Utc::now().to_rfc3339()],
1486    )?;
1487    Ok(())
1488}
1489
1490fn apply_migrations(connection: &Connection) -> anyhow::Result<()> {
1491    let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
1492    ensure!(
1493        version <= LATEST_SCHEMA_VERSION,
1494        "audio-ingress database schema version {version} is newer than supported version {LATEST_SCHEMA_VERSION}"
1495    );
1496    if version == 0 {
1497        let has_recordings = connection.query_row(
1498            "SELECT EXISTS(
1499                SELECT 1 FROM sqlite_schema
1500                WHERE type='table' AND name='audio_recordings'
1501             )",
1502            [],
1503            |row| row.get::<_, i64>(0),
1504        )? == 1;
1505        if !has_recordings {
1506            connection.execute_batch(FRESH_SCHEMA)?;
1507            return Ok(());
1508        }
1509    }
1510    if version < 1 {
1511        connection.execute_batch(INITIAL_MIGRATION)?;
1512    }
1513    if version < 2 {
1514        connection.execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)?;
1515    }
1516    if version < 3 {
1517        connection.execute_batch(TRANSCRIPTION_STATUS_MIGRATION)?;
1518    }
1519    if version < 4 {
1520        connection.execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)?;
1521    }
1522    if version < 5 {
1523        connection.execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)?;
1524    }
1525    if version < 6 {
1526        connection.execute_batch(STANDALONE_LIBRARY_MIGRATION)?;
1527    }
1528    if version < 7 {
1529        connection.execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)?;
1530    }
1531    if version < 8 {
1532        connection.execute_batch(UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION)?;
1533    }
1534    if version < 9 {
1535        connection.execute_batch(USAGE_USER_MIGRATION)?;
1536    }
1537    if version < 10 {
1538        connection.execute_batch(SPEAKER_CORRECTION_PACKETS_MIGRATION)?;
1539    }
1540    if version < 11 {
1541        connection.execute_batch(LEGACY_REVIEW_ARCHIVE_MIGRATION)?;
1542    }
1543    Ok(())
1544}
1545
1546fn ensure_private_directory(path: &Path) -> anyhow::Result<()> {
1547    fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
1548    #[cfg(unix)]
1549    {
1550        use std::os::unix::fs::PermissionsExt;
1551        fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1552            .with_context(|| format!("setting private permissions on {}", path.display()))?;
1553    }
1554    Ok(())
1555}
1556
1557fn set_private_file(path: &Path) -> anyhow::Result<()> {
1558    #[cfg(unix)]
1559    {
1560        use std::os::unix::fs::PermissionsExt;
1561        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
1562            .with_context(|| format!("setting private permissions on {}", path.display()))?;
1563    }
1564    Ok(())
1565}
1566
1567fn sync_file(path: &Path) -> anyhow::Result<()> {
1568    fs::OpenOptions::new()
1569        .read(true)
1570        .write(true)
1571        .open(path)
1572        .with_context(|| format!("opening {} for sync", path.display()))?
1573        .sync_all()
1574        .with_context(|| format!("syncing {}", path.display()))
1575}
1576
1577fn sync_directory(path: &Path) -> anyhow::Result<()> {
1578    #[cfg(unix)]
1579    fs::File::open(path)
1580        .with_context(|| format!("opening directory {} for sync", path.display()))?
1581        .sync_all()
1582        .with_context(|| format!("syncing directory {}", path.display()))?;
1583    Ok(())
1584}
1585
1586fn safe_filename(value: Option<&str>) -> String {
1587    let name = value
1588        .and_then(|value| Path::new(value).file_name())
1589        .and_then(|value| value.to_str())
1590        .unwrap_or("audio.wav");
1591    let clean = name
1592        .chars()
1593        .map(|character| {
1594            if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
1595                character
1596            } else {
1597                '_'
1598            }
1599        })
1600        .take(200)
1601        .collect::<String>();
1602    if clean.is_empty() {
1603        "audio.wav".into()
1604    } else {
1605        clean
1606    }
1607}
1608
1609fn concise(value: &str, limit: usize) -> String {
1610    let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
1611    let bounded = normalized.chars().take(limit).collect::<String>();
1612    if bounded.is_empty() {
1613        "Audio transcription failed without an error message.".into()
1614    } else {
1615        bounded
1616    }
1617}