Skip to main content

audio_engine_core/processor/
loudness_db.rs

1//! Loudness Database Persistence
2//!
3//! SQLite storage for track loudness metadata following EBU R128 standard.
4//! Enables pre-computed gain values for fast playback without real-time analysis.
5
6use rusqlite::{params, Connection, OptionalExtension};
7use std::cell::Cell;
8use std::path::{Path, PathBuf};
9use std::sync::Mutex;
10
11/// Current scanner algorithm version
12/// Increment when measurement algorithm changes to trigger rescan
13pub const CURRENT_SCAN_VERSION: i32 = 1;
14
15/// Default target loudness for streaming (LUFS)
16pub const DEFAULT_STREAMING_TARGET_LUFS: f64 = -14.0;
17
18/// Default target loudness for broadcast (LUFS)
19pub const DEFAULT_BROADCAST_TARGET_LUFS: f64 = -23.0;
20
21// ============================================================================
22// Track Loudness Record
23// ============================================================================
24
25/// Loudness metadata for a single track
26#[derive(Debug, Clone)]
27pub struct TrackLoudness {
28    /// Unique identifier (file path or hash)
29    pub track_id: String,
30    /// Original file path
31    pub file_path: String,
32    /// Integrated loudness in LUFS
33    pub integrated_lufs: f64,
34    /// True peak in dBTP
35    pub true_peak_dbtp: f64,
36    /// Loudness range in LU (optional)
37    pub loudness_range: Option<f64>,
38    /// Pre-computed track gain in dB (target - integrated)
39    pub track_gain_db: f64,
40    /// Album gain in dB (optional, for album mode)
41    pub album_gain_db: Option<f64>,
42    /// Scanner algorithm version
43    pub scan_version: i32,
44    /// Unix timestamp of scan
45    pub scanned_at: i64,
46    /// File modification time (Unix timestamp, for change detection)
47    /// FIX for Defect 40: Track file changes
48    pub file_mtime: Option<i64>,
49    /// File size in bytes (for change detection)
50    /// FIX for Defect 40: Track file changes
51    pub file_size: Option<i64>,
52    cached_gain_target_lufs: Cell<Option<f64>>,
53    cached_gain_linear: Cell<f32>,
54}
55
56impl TrackLoudness {
57    /// Create a new loudness record from measurement results
58    ///
59    /// FIX for Defect 40: Record file mtime and size for change detection.
60    /// If file metadata cannot be read (e.g., HTTP URL), these will be None.
61    pub fn new(
62        file_path: &str,
63        integrated_lufs: f64,
64        true_peak_dbtp: f64,
65        loudness_range: Option<f64>,
66        target_lufs: f64,
67    ) -> Self {
68        let track_id = Self::compute_track_id(file_path);
69        let track_gain_db = target_lufs - integrated_lufs;
70
71        // FIX for Defect 40: Get file metadata for change detection
72        let (file_mtime, file_size) = Self::get_file_metadata(file_path);
73
74        Self {
75            track_id,
76            file_path: file_path.to_string(),
77            integrated_lufs,
78            true_peak_dbtp,
79            loudness_range,
80            track_gain_db,
81            album_gain_db: None,
82            scan_version: CURRENT_SCAN_VERSION,
83            scanned_at: chrono_timestamp(),
84            file_mtime,
85            file_size,
86            cached_gain_target_lufs: Cell::new(None),
87            cached_gain_linear: Cell::new(1.0),
88        }
89    }
90
91    /// Get file modification time and size for change detection
92    /// Returns (mtime, size) or (None, None) if file is not local
93    fn get_file_metadata(path: &str) -> (Option<i64>, Option<i64>) {
94        // Skip HTTP URLs
95        if path.starts_with("http://") || path.starts_with("https://") {
96            return (None, None);
97        }
98
99        std::fs::metadata(path)
100            .ok()
101            .map(|m| {
102                let mtime = m
103                    .modified()
104                    .ok()
105                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
106                    .map(|d| d.as_secs() as i64);
107                let size = Some(m.len() as i64);
108                (mtime, size)
109            })
110            .unwrap_or((None, None))
111    }
112
113    /// Compute a unique track ID from file path
114    fn compute_track_id(path: &str) -> String {
115        // Use normalized path as ID
116        // For better collision resistance, could use hash in future
117        path.replace('\\', "/").to_lowercase()
118    }
119
120    /// Get gain in dB for a specific target loudness
121    pub fn gain_for_target(&self, target_lufs: f64) -> f64 {
122        target_lufs - self.integrated_lufs
123    }
124
125    /// Convert dB gain to linear coefficient
126    pub fn gain_linear(&self, target_lufs: f64) -> f32 {
127        if self.cached_gain_target_lufs.get() == Some(target_lufs) {
128            return self.cached_gain_linear.get();
129        }
130
131        let gain_db = self.gain_for_target(target_lufs);
132        let gain = 10.0_f64.powf(gain_db / 20.0) as f32;
133        self.cached_gain_target_lufs.set(Some(target_lufs));
134        self.cached_gain_linear.set(gain);
135        gain
136    }
137}
138
139// ============================================================================
140// Loudness Database
141// ============================================================================
142
143/// SQLite database for track loudness metadata
144pub struct LoudnessDatabase {
145    conn: Mutex<Connection>,
146    db_path: PathBuf,
147}
148
149impl LoudnessDatabase {
150    /// Open or create the loudness database
151    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, String> {
152        let db_path = path.as_ref().to_path_buf();
153
154        // Ensure parent directory exists
155        if let Some(parent) = db_path.parent() {
156            if !parent.exists() {
157                std::fs::create_dir_all(parent)
158                    .map_err(|e| format!("Failed to create database directory: {}", e))?;
159            }
160        }
161
162        let conn =
163            Connection::open(&db_path).map_err(|e| format!("Failed to open database: {}", e))?;
164
165        let db = Self {
166            conn: Mutex::new(conn),
167            db_path,
168        };
169
170        db.init_schema()?;
171        Ok(db)
172    }
173
174    /// Create an in-memory database (for testing)
175    pub fn in_memory() -> Result<Self, String> {
176        let conn = Connection::open_in_memory()
177            .map_err(|e| format!("Failed to create in-memory database: {}", e))?;
178
179        let db = Self {
180            conn: Mutex::new(conn),
181            db_path: PathBuf::from(":memory:"),
182        };
183
184        db.init_schema()?;
185        Ok(db)
186    }
187
188    /// Initialize database schema
189    fn init_schema(&self) -> Result<(), String> {
190        let conn = self.conn.lock().map_err(|e| e.to_string())?;
191
192        conn.execute_batch(
193            r#"
194            CREATE TABLE IF NOT EXISTS track_loudness (
195                track_id        TEXT PRIMARY KEY,
196                file_path       TEXT NOT NULL,
197                integrated_lufs REAL NOT NULL,
198                true_peak_dbtp  REAL NOT NULL,
199                loudness_range  REAL,
200                track_gain_db   REAL NOT NULL,
201                album_gain_db   REAL,
202                scan_version    INTEGER NOT NULL,
203                scanned_at      INTEGER NOT NULL,
204                file_mtime      INTEGER,
205                file_size       INTEGER
206            );
207            
208            CREATE INDEX IF NOT EXISTS idx_file_path ON track_loudness(file_path);
209            CREATE INDEX IF NOT EXISTS idx_scan_version ON track_loudness(scan_version);
210        "#,
211        )
212        .map_err(|e| format!("Failed to initialize schema: {}", e))?;
213
214        let mut stmt = conn
215            .prepare("PRAGMA table_info(track_loudness)")
216            .map_err(|e| format!("Failed to inspect schema: {}", e))?;
217        let existing_columns: std::collections::HashSet<String> = stmt
218            .query_map([], |row| row.get::<_, String>(1))
219            .map_err(|e| format!("Failed to read schema info: {}", e))?
220            .collect::<Result<_, _>>()
221            .map_err(|e| format!("Failed to collect schema info: {}", e))?;
222
223        if !existing_columns.contains("file_mtime") {
224            conn.execute(
225                "ALTER TABLE track_loudness ADD COLUMN file_mtime INTEGER",
226                [],
227            )
228            .map_err(|e| format!("Failed to migrate schema (file_mtime): {}", e))?;
229        }
230        if !existing_columns.contains("file_size") {
231            conn.execute(
232                "ALTER TABLE track_loudness ADD COLUMN file_size INTEGER",
233                [],
234            )
235            .map_err(|e| format!("Failed to migrate schema (file_size): {}", e))?;
236        }
237
238        Ok(())
239    }
240
241    /// Insert or update a track's loudness data
242    pub fn upsert(&self, track: &TrackLoudness) -> Result<(), String> {
243        let conn = self.conn.lock().map_err(|e| e.to_string())?;
244
245        conn.execute(
246            r#"
247            INSERT INTO track_loudness 
248                (track_id, file_path, integrated_lufs, true_peak_dbtp, 
249                 loudness_range, track_gain_db, album_gain_db, scan_version, scanned_at,
250                 file_mtime, file_size)
251            VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
252            ON CONFLICT(track_id) DO UPDATE SET
253                file_path = excluded.file_path,
254                integrated_lufs = excluded.integrated_lufs,
255                true_peak_dbtp = excluded.true_peak_dbtp,
256                loudness_range = excluded.loudness_range,
257                track_gain_db = excluded.track_gain_db,
258                album_gain_db = excluded.album_gain_db,
259                scan_version = excluded.scan_version,
260                scanned_at = excluded.scanned_at,
261                file_mtime = excluded.file_mtime,
262                file_size = excluded.file_size
263            "#,
264            params![
265                track.track_id,
266                track.file_path,
267                track.integrated_lufs,
268                track.true_peak_dbtp,
269                track.loudness_range,
270                track.track_gain_db,
271                track.album_gain_db,
272                track.scan_version,
273                track.scanned_at,
274                track.file_mtime,
275                track.file_size,
276            ],
277        )
278        .map_err(|e| format!("Failed to upsert track: {}", e))?;
279
280        Ok(())
281    }
282
283    /// Get loudness data for a track by file path
284    pub fn get(&self, file_path: &str) -> Result<Option<TrackLoudness>, String> {
285        let conn = self.conn.lock().map_err(|e| e.to_string())?;
286        let track_id = TrackLoudness::compute_track_id(file_path);
287
288        let result = conn
289            .query_row(
290                r#"
291            SELECT track_id, file_path, integrated_lufs, true_peak_dbtp,
292                   loudness_range, track_gain_db, album_gain_db, scan_version, scanned_at,
293                   file_mtime, file_size
294            FROM track_loudness
295            WHERE track_id = ?1
296            "#,
297                params![track_id],
298                |row| {
299                    Ok(TrackLoudness {
300                        track_id: row.get(0)?,
301                        file_path: row.get(1)?,
302                        integrated_lufs: row.get(2)?,
303                        true_peak_dbtp: row.get(3)?,
304                        loudness_range: row.get(4)?,
305                        track_gain_db: row.get(5)?,
306                        album_gain_db: row.get(6)?,
307                        scan_version: row.get(7)?,
308                        scanned_at: row.get(8)?,
309                        file_mtime: row.get(9)?,
310                        file_size: row.get(10)?,
311                        cached_gain_target_lufs: Cell::new(None),
312                        cached_gain_linear: Cell::new(1.0),
313                    })
314                },
315            )
316            .optional()
317            .map_err(|e| format!("Failed to query track: {}", e))?;
318
319        Ok(result)
320    }
321
322    /// Get loudness data only when the cached record is still fresh.
323    ///
324    /// This centralizes the cache-hit contract used by both HTTP analysis
325    /// handlers and playback loading: scan version, file mtime, and file size
326    /// must all still match before a record may skip EBU R128 analysis.
327    pub fn get_fresh(&self, file_path: &str) -> Result<Option<TrackLoudness>, String> {
328        if self.needs_scan(file_path)? {
329            return Ok(None);
330        }
331
332        self.get(file_path)
333    }
334
335    /// Check if a track needs scanning (not in DB, outdated version, or file changed)
336    ///
337    /// FIX for Defect 40: Also check file mtime and size for change detection.
338    /// This handles the case where a file is replaced but keeps the same path.
339    pub fn needs_scan(&self, file_path: &str) -> Result<bool, String> {
340        let conn = self.conn.lock().map_err(|e| e.to_string())?;
341        let track_id = TrackLoudness::compute_track_id(file_path);
342
343        let result: Option<(i32, Option<i64>, Option<i64>)> = conn.query_row(
344            "SELECT scan_version, file_mtime, file_size FROM track_loudness WHERE track_id = ?1",
345            params![track_id],
346            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
347        ).optional().map_err(|e| format!("Failed to check track: {}", e))?;
348
349        match result {
350            None => Ok(true), // Not in database
351            Some((version, db_mtime, db_size)) => {
352                // Check scan version
353                if version < CURRENT_SCAN_VERSION {
354                    return Ok(true); // Outdated scanner version
355                }
356
357                // FIX for Defect 40: Check file modification time and size
358                // Only check for local files (not HTTP URLs)
359                if !file_path.starts_with("http://") && !file_path.starts_with("https://") {
360                    if let Ok(metadata) = std::fs::metadata(file_path) {
361                        let current_mtime = metadata
362                            .modified()
363                            .ok()
364                            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
365                            .map(|d| d.as_secs() as i64);
366                        let current_size = Some(metadata.len() as i64);
367
368                        // If mtime or size changed, need rescan
369                        if current_mtime != db_mtime || current_size != db_size {
370                            log::info!(
371                                "File changed, needs rescan: {} (mtime: {:?} -> {:?}, size: {:?} -> {:?})",
372                                file_path, db_mtime, current_mtime, db_size, current_size
373                            );
374                            return Ok(true);
375                        }
376                    }
377                }
378
379                Ok(false) // No changes detected
380            }
381        }
382    }
383
384    /// Get all tracks that need rescanning
385    pub fn get_outdated_tracks(&self) -> Result<Vec<String>, String> {
386        let conn = self.conn.lock().map_err(|e| e.to_string())?;
387
388        let mut stmt = conn
389            .prepare("SELECT file_path FROM track_loudness WHERE scan_version < ?1")
390            .map_err(|e| format!("Failed to prepare statement: {}", e))?;
391
392        let tracks: Vec<String> = stmt
393            .query_map(params![CURRENT_SCAN_VERSION], |row| row.get(0))
394            .map_err(|e| format!("Failed to query outdated tracks: {}", e))?
395            .filter_map(|r| r.ok())
396            .collect();
397
398        Ok(tracks)
399    }
400
401    /// Batch insert multiple tracks (for initial scan)
402    pub fn batch_upsert(&self, tracks: &[TrackLoudness]) -> Result<usize, String> {
403        let mut conn = self.conn.lock().map_err(|e| e.to_string())?;
404        let tx = conn
405            .transaction()
406            .map_err(|e| format!("Failed to begin transaction: {}", e))?;
407
408        let mut count = 0;
409        for track in tracks {
410            tx.execute(
411                r#"
412                INSERT INTO track_loudness 
413                    (track_id, file_path, integrated_lufs, true_peak_dbtp, 
414                     loudness_range, track_gain_db, album_gain_db, scan_version, scanned_at,
415                     file_mtime, file_size)
416                VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
417                ON CONFLICT(track_id) DO UPDATE SET
418                    file_path = excluded.file_path,
419                    integrated_lufs = excluded.integrated_lufs,
420                    true_peak_dbtp = excluded.true_peak_dbtp,
421                    loudness_range = excluded.loudness_range,
422                    track_gain_db = excluded.track_gain_db,
423                    album_gain_db = excluded.album_gain_db,
424                    scan_version = excluded.scan_version,
425                    scanned_at = excluded.scanned_at,
426                    file_mtime = excluded.file_mtime,
427                    file_size = excluded.file_size
428                "#,
429                params![
430                    track.track_id,
431                    track.file_path,
432                    track.integrated_lufs,
433                    track.true_peak_dbtp,
434                    track.loudness_range,
435                    track.track_gain_db,
436                    track.album_gain_db,
437                    track.scan_version,
438                    track.scanned_at,
439                    track.file_mtime,
440                    track.file_size,
441                ],
442            )
443            .map_err(|e| format!("Failed to upsert track {}: {}", track.file_path, e))?;
444            count += 1;
445        }
446
447        tx.commit()
448            .map_err(|e| format!("Failed to commit transaction: {}", e))?;
449        Ok(count)
450    }
451
452    /// Update album gain for multiple tracks (same album)
453    ///
454    /// FIX for Defect 41: Wrap in transaction for atomicity.
455    /// If any update fails or process crashes, all changes are rolled back.
456    pub fn set_album_gain(&self, track_ids: &[&str], album_gain_db: f64) -> Result<(), String> {
457        let mut conn = self.conn.lock().map_err(|e| e.to_string())?;
458
459        // FIX for Defect 41: Use transaction for atomic batch update
460        let tx = conn
461            .transaction()
462            .map_err(|e| format!("Failed to begin transaction: {}", e))?;
463
464        for track_id in track_ids {
465            tx.execute(
466                "UPDATE track_loudness SET album_gain_db = ?1 WHERE track_id = ?2",
467                params![album_gain_db, track_id],
468            )
469            .map_err(|e| format!("Failed to update album gain for {}: {}", track_id, e))?;
470        }
471
472        tx.commit()
473            .map_err(|e| format!("Failed to commit album gain transaction: {}", e))?;
474
475        Ok(())
476    }
477
478    /// Delete a track from the database
479    pub fn delete(&self, file_path: &str) -> Result<bool, String> {
480        let conn = self.conn.lock().map_err(|e| e.to_string())?;
481        let track_id = TrackLoudness::compute_track_id(file_path);
482
483        let affected = conn
484            .execute(
485                "DELETE FROM track_loudness WHERE track_id = ?1",
486                params![track_id],
487            )
488            .map_err(|e| format!("Failed to delete track: {}", e))?;
489
490        Ok(affected > 0)
491    }
492
493    /// Get database statistics
494    pub fn stats(&self) -> Result<DatabaseStats, String> {
495        let conn = self.conn.lock().map_err(|e| e.to_string())?;
496
497        let total_tracks: i64 = conn
498            .query_row("SELECT COUNT(*) FROM track_loudness", [], |row| row.get(0))
499            .map_err(|e| format!("Failed to count tracks: {}", e))?;
500
501        let outdated_tracks: i64 = conn
502            .query_row(
503                "SELECT COUNT(*) FROM track_loudness WHERE scan_version < ?1",
504                params![CURRENT_SCAN_VERSION],
505                |row| row.get(0),
506            )
507            .map_err(|e| format!("Failed to count outdated tracks: {}", e))?;
508
509        let with_album_gain: i64 = conn
510            .query_row(
511                "SELECT COUNT(*) FROM track_loudness WHERE album_gain_db IS NOT NULL",
512                [],
513                |row| row.get(0),
514            )
515            .map_err(|e| format!("Failed to count album gain tracks: {}", e))?;
516
517        Ok(DatabaseStats {
518            total_tracks,
519            outdated_tracks,
520            with_album_gain,
521            current_scan_version: CURRENT_SCAN_VERSION,
522        })
523    }
524
525    /// Get database path
526    pub fn path(&self) -> &Path {
527        &self.db_path
528    }
529}
530
531// ============================================================================
532// Database Statistics
533// ============================================================================
534
535/// Statistics about the loudness database
536#[derive(Debug, Clone, serde::Serialize)]
537pub struct DatabaseStats {
538    pub total_tracks: i64,
539    pub outdated_tracks: i64,
540    pub with_album_gain: i64,
541    pub current_scan_version: i32,
542}
543
544// ============================================================================
545// Helper Functions
546// ============================================================================
547
548/// Get current Unix timestamp in seconds
549fn chrono_timestamp() -> i64 {
550    std::time::SystemTime::now()
551        .duration_since(std::time::UNIX_EPOCH)
552        .map(|d| d.as_secs() as i64)
553        .unwrap_or(0)
554}
555
556// ============================================================================
557// Tests
558// ============================================================================
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563    use std::time::{SystemTime, UNIX_EPOCH};
564
565    #[test]
566    fn test_database_basic_operations() {
567        let db = LoudnessDatabase::in_memory().unwrap();
568
569        let track = TrackLoudness::new(
570            "/music/test.flac",
571            -18.5,     // integrated_lufs
572            -0.5,      // true_peak_dbtp
573            Some(6.2), // loudness_range
574            DEFAULT_STREAMING_TARGET_LUFS,
575        );
576
577        // Insert
578        db.upsert(&track).unwrap();
579
580        // Retrieve
581        let retrieved = db.get("/music/test.flac").unwrap().unwrap();
582        assert_eq!(retrieved.integrated_lufs, -18.5);
583        assert_eq!(retrieved.track_gain_db, 4.5); // -14 - (-18.5)
584
585        // Check needs_scan
586        assert!(!db.needs_scan("/music/test.flac").unwrap());
587        assert!(db.needs_scan("/music/other.flac").unwrap());
588    }
589
590    #[test]
591    fn test_gain_calculation() {
592        let track = TrackLoudness::new("/test.flac", -20.0, -1.0, None, -14.0);
593
594        assert_eq!(track.track_gain_db, 6.0); // -14 - (-20)
595        assert!((track.gain_linear(-14.0) - 1.995).abs() < 0.01);
596
597        // Different target
598        assert_eq!(track.gain_for_target(-23.0), -3.0);
599    }
600
601    #[test]
602    fn track_gain_linear_reuses_same_target_and_invalidates_on_change() {
603        let track = TrackLoudness::new("/test.flac", -20.0, -1.0, None, -14.0);
604
605        let first = track.gain_linear(-14.0);
606        let second = track.gain_linear(-14.0);
607        let third = track.gain_linear(-23.0);
608
609        assert_eq!(first.to_bits(), second.to_bits());
610        assert_eq!(first.to_bits(), track.gain_linear(-14.0).to_bits());
611        assert_eq!(third.to_bits(), track.gain_linear(-23.0).to_bits());
612        assert_ne!(first.to_bits(), third.to_bits());
613    }
614
615    #[test]
616    fn get_fresh_rejects_changed_local_file() {
617        let db = LoudnessDatabase::in_memory().unwrap();
618        let unique = SystemTime::now()
619            .duration_since(UNIX_EPOCH)
620            .unwrap_or_default()
621            .as_nanos();
622        let path = std::env::temp_dir().join(format!(
623            "audio_player_loudness_fresh_{}_{}.flac",
624            std::process::id(),
625            unique
626        ));
627        std::fs::write(&path, b"initial").unwrap();
628        let path_string = path.to_string_lossy().to_string();
629
630        let track = TrackLoudness::new(&path_string, -18.0, -1.0, None, -14.0);
631        db.upsert(&track).unwrap();
632        assert!(db.get_fresh(&path_string).unwrap().is_some());
633
634        std::fs::write(&path, b"changed file contents").unwrap();
635        assert!(db.get_fresh(&path_string).unwrap().is_none());
636
637        let _ = std::fs::remove_file(path);
638    }
639}