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