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