diff --git a/CHANGELOG.md b/CHANGELOG.md index 87006c0a..d3f6f8ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -249,6 +249,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Fixed +### Analytics — Opus waveform and loudness analysis + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#883](https://github.com/Psychotoxical/psysonic/pull/883)** + +* **Opus tracks:** waveform, LUFS, and enrichment analysis now use the same `symphonia-adapter-libopus` registry as playback — previously Symphonia could demux Ogg Opus but failed at decoder creation, leaving `.opus` libraries without analysis data. + + + ### In-page browse — virtual scroll and cover-art priority **By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)** diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index eb648eb5..a0e9fe26 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4099,6 +4099,7 @@ dependencies = [ "serde", "serde_json", "symphonia", + "symphonia-adapter-libopus", "tauri", "tokio", ] diff --git a/src-tauri/crates/psysonic-analysis/Cargo.toml b/src-tauri/crates/psysonic-analysis/Cargo.toml index f446d4eb..6cd78643 100644 --- a/src-tauri/crates/psysonic-analysis/Cargo.toml +++ b/src-tauri/crates/psysonic-analysis/Cargo.toml @@ -18,6 +18,7 @@ ebur128 = "0.1" md5 = "0.8" rusqlite = { version = "0.39", features = ["bundled"] } symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] } +symphonia-adapter-libopus = "0.2.9" oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] } [dev-dependencies] diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs b/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs index c16b4943..86dfdbf4 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_cache/compute.rs @@ -14,6 +14,7 @@ use tauri::{Manager, Runtime}; use psysonic_core::track_enrichment::TrackEnrichmentOutcome; use crate::analysis_perf::AnalysisSeedTimings; +use crate::codec::make_decoder; use super::store::{now_unix_ts, AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry}; @@ -47,6 +48,7 @@ pub fn seed_from_bytes_execute( server_id: &str, track_id: &str, bytes: &[u8], + format_hint: Option<&str>, notify_ui: bool, ) -> Result<(SeedFromBytesOutcome, AnalysisSeedTimings), String> { let seed_started = Instant::now(); @@ -61,7 +63,8 @@ pub fn seed_from_bytes_execute( AnalysisSeedTimings::default(), )); }; - let (outcome, md5_16kb) = seed_from_bytes_into_cache(&cache, server_id, track_id, bytes)?; + let (outcome, md5_16kb) = + seed_from_bytes_into_cache(&cache, server_id, track_id, bytes, format_hint)?; let seed_ms = seed_started.elapsed().as_millis() as u64; // E2 bridge (analysis → library content_hash): once the playback-derived // md5_16kb is known — whether freshly written or already cached — record it @@ -129,6 +132,7 @@ pub fn seed_from_bytes_into_cache( server_id: &str, track_id: &str, bytes: &[u8], + format_hint: Option<&str>, ) -> Result<(SeedFromBytesOutcome, String), String> { let started = Instant::now(); // Write under the playback server's scope. @@ -168,7 +172,8 @@ pub fn seed_from_bytes_into_cache( let build = (|| -> Result<(bool, usize), String> { cache.touch_track_status(&key, "queued")?; - let (wf_bins, loudness_opt, used_pcm_decode) = match analyze_loudness_and_waveform(bytes, -16.0, 500) { + let (wf_bins, loudness_opt, used_pcm_decode) = + match analyze_loudness_and_waveform(bytes, -16.0, 500, format_hint) { Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => { ( bins, @@ -275,15 +280,16 @@ fn analyze_loudness_and_waveform( bytes: &[u8], target_lufs: f64, bin_count: usize, + format_hint: Option<&str>, ) -> Option<(f64, f64, f64, f64, Vec)> { if bytes.is_empty() || bin_count == 0 { return None; } - let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?; + let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes, format_hint)?; if decoded_frames == 0 { return None; } - let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs))?; + let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs), format_hint)?; let (i, t, r, tgt) = scanned.loudness?; Some((i, t, r, tgt, scanned.bins)) } @@ -298,10 +304,36 @@ struct DecodeSession { timeline_hint: Option, } -fn open_decode_session(bytes: &[u8]) -> Option { +fn format_hint_from_bytes(bytes: &[u8]) -> Option { + if bytes.len() < 4 { + return None; + } + if bytes[0..4] == *b"OggS" { + return Some("ogg".into()); + } + if bytes.len() >= 4 && bytes[0..4] == *b"fLaC" { + return Some("flac".into()); + } + if bytes.len() >= 12 && bytes[0..4] == *b"RIFF" && bytes[8..12] == *b"WAVE" { + return Some("wav".into()); + } + let scan = bytes.len().min(4096).saturating_sub(4); + for i in 0..=scan { + if bytes[i..i + 4] == *b"ftyp" { + return Some("m4a".into()); + } + } + None +} + +fn open_decode_session(bytes: &[u8], format_hint: Option<&str>) -> Option { let source = Box::new(Cursor::new(bytes.to_vec())); let mss = MediaSourceStream::new(source, Default::default()); - let hint = Hint::new(); + let sniffed = format_hint_from_bytes(bytes); + let mut hint = Hint::new(); + if let Some(ext) = format_hint.or(sniffed.as_deref()) { + hint.with_extension(ext); + } let probed = symphonia::default::get_probe() .format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default()) .ok()?; @@ -320,7 +352,7 @@ fn open_decode_session(bytes: &[u8]) -> Option { let track_id = track.id; let timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0); let codec_params = track.codec_params.clone(); - let decoder = match symphonia::default::get_codecs().make(&codec_params, &DecoderOptions::default()) { + let decoder = match make_decoder(&codec_params, &DecoderOptions::default()) { Ok(v) => v, Err(e) => { crate::app_deprintln!("[analysis] decoder make failed: {}", e); @@ -334,9 +366,9 @@ fn open_decode_session(bytes: &[u8]) -> Option { /// `codec_params.n_frames` when the container reports total track length — used /// as a **fixed** waveform time axis so partial decodes do not remap every bin /// when the buffer grows. -fn count_mono_frames_from_audio_bytes(bytes: &[u8]) -> Option<(u64, Option)> { +fn count_mono_frames_from_audio_bytes(bytes: &[u8], format_hint: Option<&str>) -> Option<(u64, Option)> { let DecodeSession { mut format, mut decoder, track_id, timeline_hint } = - open_decode_session(bytes)?; + open_decode_session(bytes, format_hint)?; let mut total: u64 = 0; let mut loop_i: u32 = 0; @@ -399,8 +431,9 @@ fn decode_scan_pcm( decoded_frames: u64, timeline_hint: Option, loudness_target_lufs: Option, + format_hint: Option<&str>, ) -> Option { - let DecodeSession { mut format, mut decoder, track_id, .. } = open_decode_session(bytes)?; + let DecodeSession { mut format, mut decoder, track_id, .. } = open_decode_session(bytes, format_hint)?; let mut bin_max = vec![0.0f32; bin_count]; let mut bin_sum = vec![0.0f32; bin_count]; @@ -598,7 +631,7 @@ pub fn analysis_pcm_window(total_duration_sec: f64, window_sec: f64) -> PcmAnaly /// Best-effort container duration from codec metadata (seconds). pub fn audio_duration_from_bytes(bytes: &[u8]) -> Option { - let session = open_decode_session(bytes)?; + let session = open_decode_session(bytes, None)?; let sample_rate = session .format .default_track() @@ -623,7 +656,7 @@ pub fn decode_mono_pcm_window( mut decoder, track_id, .. - } = open_decode_session(bytes).ok_or_else(|| "failed to open audio decode session".to_string())?; + } = open_decode_session(bytes, None).ok_or_else(|| "failed to open audio decode session".to_string())?; if start_sec.is_finite() && start_sec > 0.0 { let time: Time = start_sec.max(0.0).into(); @@ -654,7 +687,7 @@ pub fn decode_mono_pcm_limited( mut decoder, track_id, .. - } = open_decode_session(bytes).ok_or_else(|| "failed to open audio decode session".to_string())?; + } = open_decode_session(bytes, None).ok_or_else(|| "failed to open audio decode session".to_string())?; decode_mono_pcm_from_session(&mut format, &mut decoder, track_id, max_seconds) } @@ -927,7 +960,7 @@ mod tests { #[test] fn count_mono_frames_returns_decoded_length_for_synthetic_wav() { let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100); - let (frames, _hint) = count_mono_frames_from_audio_bytes(&wav) + let (frames, _hint) = count_mono_frames_from_audio_bytes(&wav, None) .expect("WAV decode must succeed"); // 1 second × 44.1 kHz mono = 44 100 frames; allow ±1 packet tolerance. assert!( @@ -938,18 +971,18 @@ mod tests { #[test] fn count_mono_frames_returns_none_for_garbage_bytes() { - assert!(count_mono_frames_from_audio_bytes(b"not an audio file").is_none()); + assert!(count_mono_frames_from_audio_bytes(b"not an audio file", None).is_none()); } #[test] fn count_mono_frames_returns_none_for_empty_bytes() { - assert!(count_mono_frames_from_audio_bytes(&[]).is_none()); + assert!(count_mono_frames_from_audio_bytes(&[], None).is_none()); } #[test] fn analyze_loudness_and_waveform_returns_loudness_for_synthetic_sine() { let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100); - let result = analyze_loudness_and_waveform(&wav, -14.0, 100) + let result = analyze_loudness_and_waveform(&wav, -14.0, 100, None) .expect("WAV decode must succeed"); let (integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins) = result; assert_eq!(bins.len(), 200, "bins layout is peak_u8 + mean_u8 = 2 * bin_count"); @@ -974,19 +1007,19 @@ mod tests { #[test] fn analyze_loudness_returns_none_for_zero_bin_count() { let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.5), 44_100); - assert!(analyze_loudness_and_waveform(&wav, -14.0, 0).is_none()); + assert!(analyze_loudness_and_waveform(&wav, -14.0, 0, None).is_none()); } #[test] fn analyze_loudness_returns_none_for_empty_bytes() { - assert!(analyze_loudness_and_waveform(&[], -14.0, 100).is_none()); + assert!(analyze_loudness_and_waveform(&[], -14.0, 100, None).is_none()); } #[test] fn seed_from_bytes_into_cache_upserts_waveform_and_loudness_for_wav() { let cache = AnalysisCache::open_in_memory(); let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100); - let (outcome, md5) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track", &wav).unwrap(); + let (outcome, md5) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track", &wav, None).unwrap(); assert_eq!(outcome, SeedFromBytesOutcome::Upserted); assert_eq!(md5, md5_first_16kb(&wav), "outcome carries the content fingerprint"); @@ -1007,7 +1040,7 @@ mod tests { fn seed_from_bytes_into_cache_writes_under_the_given_server_scope() { let cache = AnalysisCache::open_in_memory(); let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.5), 44_100); - seed_from_bytes_into_cache(&cache, "server-x", "scoped-track", &wav).unwrap(); + seed_from_bytes_into_cache(&cache, "server-x", "scoped-track", &wav, None).unwrap(); let md5 = md5_first_16kb(&wav); let scoped = TrackKey { @@ -1028,9 +1061,9 @@ mod tests { fn seed_from_bytes_into_cache_returns_skipped_on_second_call() { let cache = AnalysisCache::open_in_memory(); let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100); - let (first, _) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track-2", &wav).unwrap(); + let (first, _) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track-2", &wav, None).unwrap(); assert_eq!(first, SeedFromBytesOutcome::Upserted); - let (second, _) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track-2", &wav).unwrap(); + let (second, _) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track-2", &wav, None).unwrap(); assert_eq!( second, SeedFromBytesOutcome::SkippedWaveformCacheHit, @@ -1044,7 +1077,7 @@ mod tests { // Garbage bytes — Symphonia probe fails, the pipeline falls back to // `derive_waveform_bins` (no loudness row gets cached). let bytes = vec![0xAAu8; 8 * 1024]; - let (outcome, _) = seed_from_bytes_into_cache(&cache, "server-a", "garbage", &bytes).unwrap(); + let (outcome, _) = seed_from_bytes_into_cache(&cache, "server-a", "garbage", &bytes, None).unwrap(); assert_eq!(outcome, SeedFromBytesOutcome::Upserted); let key = TrackKey { @@ -1126,8 +1159,8 @@ mod tests { #[test] fn decode_scan_pcm_supports_waveform_only_mode_without_loudness() { let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100); - let (frames, hint) = count_mono_frames_from_audio_bytes(&wav).expect("frame counting"); - let scanned = decode_scan_pcm(&wav, 64, frames, hint, None).expect("scan must succeed"); + let (frames, hint) = count_mono_frames_from_audio_bytes(&wav, None).expect("frame counting"); + let scanned = decode_scan_pcm(&wav, 64, frames, hint, None, None).expect("scan must succeed"); assert_eq!(scanned.bins.len(), 128); assert!(scanned.loudness.is_none()); } @@ -1135,8 +1168,8 @@ mod tests { #[test] fn decode_scan_pcm_with_loudness_target_returns_loudness_tuple() { let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100); - let (frames, hint) = count_mono_frames_from_audio_bytes(&wav).expect("frame counting"); - let scanned = decode_scan_pcm(&wav, 64, frames, hint, Some(-14.0)).expect("scan must succeed"); + let (frames, hint) = count_mono_frames_from_audio_bytes(&wav, None).expect("frame counting"); + let scanned = decode_scan_pcm(&wav, 64, frames, hint, Some(-14.0), None).expect("scan must succeed"); assert_eq!(scanned.bins.len(), 128); let (integrated_lufs, true_peak, recommended_gain_db, target_lufs) = scanned.loudness.expect("loudness tuple must be present"); @@ -1148,7 +1181,7 @@ mod tests { #[test] fn decode_scan_pcm_returns_none_for_non_audio_input() { - assert!(decode_scan_pcm(b"nope", 32, 10, None, Some(-14.0)).is_none()); + assert!(decode_scan_pcm(b"nope", 32, 10, None, Some(-14.0), None).is_none()); } #[test] @@ -1178,7 +1211,7 @@ mod tests { assert!(!cache.loudness_row_exists_for_key(&key).unwrap()); let (outcome, _) = - seed_from_bytes_into_cache(&cache, "server-a", "track-reseed", &wav).unwrap(); + seed_from_bytes_into_cache(&cache, "server-a", "track-reseed", &wav, None).unwrap(); assert_eq!(outcome, SeedFromBytesOutcome::Upserted); assert!(cache.loudness_row_exists_for_key(&key).unwrap()); } @@ -1219,14 +1252,14 @@ mod tests { #[test] fn decode_scan_pcm_returns_none_when_no_frames_decoded() { let wav = build_mono_pcm16_wav(&[], 44_100); - assert!(analyze_loudness_and_waveform(&wav, -14.0, 64).is_none()); + assert!(analyze_loudness_and_waveform(&wav, -14.0, 64, None).is_none()); } #[test] fn decode_scan_pcm_ignores_oversized_timeline_hint() { let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100); - let (frames, _hint) = count_mono_frames_from_audio_bytes(&wav).expect("frame counting"); - let scanned = decode_scan_pcm(&wav, 64, frames, Some(frames * 10), None).unwrap(); + let (frames, _hint) = count_mono_frames_from_audio_bytes(&wav, None).expect("frame counting"); + let scanned = decode_scan_pcm(&wav, 64, frames, Some(frames * 10), None, None).unwrap(); assert_eq!(scanned.bins.len(), 128); } @@ -1235,7 +1268,7 @@ mod tests { let app = tauri::test::mock_app(); let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.25), 44_100); let handle = app.handle().clone(); - let (outcome, timings) = seed_from_bytes_execute(&handle, "s", "t", &wav, true) + let (outcome, timings) = seed_from_bytes_execute(&handle, "s", "t", &wav, None, true) .expect("seed execute should return a graceful skip"); assert_eq!(outcome, SeedFromBytesOutcome::SkippedNoAnalysisCache); assert_eq!(timings.seed_ms, 0); @@ -1250,12 +1283,12 @@ mod tests { let handle = app.handle().clone(); let (first, timings_first) = - seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav, true).unwrap(); + seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav, None, true).unwrap(); assert_eq!(first, SeedFromBytesOutcome::Upserted); assert!(timings_first.seed_ms <= 30_000); let (second, timings_second) = - seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav, true).unwrap(); + seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav, None, true).unwrap(); assert_eq!(second, SeedFromBytesOutcome::SkippedWaveformCacheHit); assert!(timings_second.seed_ms <= 30_000); } diff --git a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs index 73f85cbc..3f7f5c1b 100644 --- a/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs +++ b/src-tauri/crates/psysonic-analysis/src/analysis_runtime.rs @@ -384,9 +384,10 @@ pub async fn enqueue_track_analysis( server_id: &str, track_id: &str, bytes: &[u8], + format_hint: Option<&str>, priority: AnalysisBackfillPriority, ) -> Result { - enqueue_track_analysis_with_fetch(app, server_id, track_id, bytes, priority, 0).await + enqueue_track_analysis_with_fetch(app, server_id, track_id, bytes, format_hint, priority, 0).await } async fn enqueue_track_analysis_with_fetch( @@ -394,6 +395,7 @@ async fn enqueue_track_analysis_with_fetch( server_id: &str, track_id: &str, bytes: &[u8], + format_hint: Option<&str>, priority: AnalysisBackfillPriority, fetch_ms: u64, ) -> Result { @@ -424,6 +426,7 @@ async fn enqueue_track_analysis_with_fetch( server_id.to_string(), track_id.to_string(), bytes.to_vec(), + format_hint.map(str::to_string), priority, fetch_ms, ) @@ -505,7 +508,12 @@ pub async fn enqueue_track_analysis_from_file( if bytes.is_empty() { return Ok(EnqueueTrackAnalysisOutcome::Complete); } - enqueue_track_analysis(app, server_id, track_id, &bytes, priority).await + let format_hint = file_path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .filter(|e| !e.is_empty()); + enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await } /// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`]. @@ -516,7 +524,7 @@ pub async fn enqueue_analysis_seed( bytes: &[u8], ) -> Result { let priority = analysis_backfill_resolve_priority(app, server_id, track_id, None); - let outcome = enqueue_track_analysis(app, server_id, track_id, bytes, priority).await?; + let outcome = enqueue_track_analysis(app, server_id, track_id, bytes, None, priority).await?; Ok(!matches!(outcome, EnqueueTrackAnalysisOutcome::Complete)) } @@ -659,6 +667,7 @@ async fn spawn_backfill_slots(app: &tauri::AppHandle, shared: &Arc, + format_hint: Option, waiters: Vec, /// HTTP download time when this job came from the backfill worker. fetch_ms: u64, @@ -1028,6 +1038,7 @@ impl AnalysisCpuSeedQueueState { server_id: String, track_id: String, bytes: Vec, + format_hint: Option, priority: AnalysisBackfillPriority, fetch_ms: u64, ) -> ( @@ -1049,6 +1060,7 @@ impl AnalysisCpuSeedQueueState { let mut job = self.tier_deque_mut(existing_tier).remove(pos).unwrap(); job.server_id = server_id; job.bytes = bytes; + job.format_hint = format_hint; job.fetch_ms = fetch_ms; job.waiters.push(done_tx); if priority > existing_tier { @@ -1065,6 +1077,7 @@ impl AnalysisCpuSeedQueueState { server_id, track_id: track_id.clone(), bytes, + format_hint, waiters: vec![done_tx], fetch_ms, priority, @@ -1257,12 +1270,14 @@ async fn spawn_cpu_seed_slots(app: &tauri::AppHandle, shared: &Arc, + format_hint: Option, priority: AnalysisBackfillPriority, fetch_ms: u64, ) -> Result { let shared = analysis_cpu_seed_shared(&app); let rx = { let mut st = shared.state.lock().unwrap_or_else(|e| e.into_inner()); - let (kind, rx) = st.enqueue(server_id, track_id.clone(), bytes, priority, fetch_ms); + let (kind, rx) = st.enqueue(server_id, track_id.clone(), bytes, format_hint, priority, fetch_ms); crate::app_deprintln!("[analysis] cpu-seed submit: kind={kind:?} priority={priority:?}"); drop(st); shared.ping_worker(); @@ -1587,6 +1603,7 @@ mod tests { String::new(), "a".into(), vec![], + None, AnalysisBackfillPriority::Low, 0, ); @@ -1601,6 +1618,7 @@ mod tests { String::new(), "first".into(), vec![], + None, AnalysisBackfillPriority::Low, 0, ); @@ -1608,6 +1626,7 @@ mod tests { String::new(), "hot".into(), vec![], + None, AnalysisBackfillPriority::High, 0, ); @@ -1622,6 +1641,7 @@ mod tests { "server-a".into(), "dup".into(), vec![1, 2, 3], + None, AnalysisBackfillPriority::Low, 0, ); @@ -1629,6 +1649,7 @@ mod tests { "server-b".into(), "dup".into(), vec![4, 5, 6], + None, AnalysisBackfillPriority::Low, 0, ); @@ -1647,6 +1668,7 @@ mod tests { String::new(), "first".into(), vec![], + None, AnalysisBackfillPriority::Low, 0, ); @@ -1654,6 +1676,7 @@ mod tests { String::new(), "dup".into(), vec![], + None, AnalysisBackfillPriority::Low, 0, ); @@ -1661,6 +1684,7 @@ mod tests { String::new(), "dup".into(), vec![], + None, AnalysisBackfillPriority::High, 0, ); @@ -1677,6 +1701,7 @@ mod tests { String::new(), "active".into(), vec![], + None, AnalysisBackfillPriority::Low, 0, ); @@ -1692,6 +1717,7 @@ mod tests { String::new(), "a".into(), vec![], + None, AnalysisBackfillPriority::Low, 0, ); @@ -1699,6 +1725,7 @@ mod tests { String::new(), "b".into(), vec![], + None, AnalysisBackfillPriority::Low, 0, ); @@ -1706,6 +1733,7 @@ mod tests { String::new(), "a".into(), vec![], + None, AnalysisBackfillPriority::Low, 0, ); @@ -1713,6 +1741,7 @@ mod tests { String::new(), "c".into(), vec![], + None, AnalysisBackfillPriority::Low, 0, ); @@ -1731,6 +1760,7 @@ mod tests { String::new(), "doomed".into(), vec![], + None, AnalysisBackfillPriority::Low, 0, ); diff --git a/src-tauri/crates/psysonic-analysis/src/codec.rs b/src-tauri/crates/psysonic-analysis/src/codec.rs new file mode 100644 index 00000000..a589b1b3 --- /dev/null +++ b/src-tauri/crates/psysonic-analysis/src/codec.rs @@ -0,0 +1,21 @@ +//! Symphonia codec registry — mirrors `psysonic-audio::codec` (Opus via libopus). +use std::sync::OnceLock; + +use symphonia::core::codecs::{CodecRegistry, DecoderOptions}; + +pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(|| { + let mut registry = CodecRegistry::new(); + symphonia::default::register_enabled_codecs(&mut registry); + registry.register_all::(); + registry + }) +} + +pub(crate) fn make_decoder( + params: &symphonia::core::codecs::CodecParameters, + opts: &DecoderOptions, +) -> Result, symphonia::core::errors::Error> { + psysonic_codec_registry().make(params, opts) +} diff --git a/src-tauri/crates/psysonic-analysis/src/lib.rs b/src-tauri/crates/psysonic-analysis/src/lib.rs index 2205461b..a21046c4 100644 --- a/src-tauri/crates/psysonic-analysis/src/lib.rs +++ b/src-tauri/crates/psysonic-analysis/src/lib.rs @@ -8,6 +8,7 @@ pub mod analysis_cache; pub mod analysis_perf; pub mod analysis_runtime; +mod codec; pub mod commands; pub mod track_analysis_plan; pub mod track_enrichment; diff --git a/src-tauri/crates/psysonic-audio/src/analysis_dispatch.rs b/src-tauri/crates/psysonic-audio/src/analysis_dispatch.rs index c8b896a5..25aad326 100644 --- a/src-tauri/crates/psysonic-audio/src/analysis_dispatch.rs +++ b/src-tauri/crates/psysonic-audio/src/analysis_dispatch.rs @@ -200,6 +200,7 @@ pub(crate) async fn dispatch_track_analysis_bytes( server_id, track_id, &bytes, + None, priority, ) .await diff --git a/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs index 0ab5706c..ed3cd08c 100644 --- a/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs +++ b/src-tauri/crates/psysonic-syncfs/src/cache/hot.rs @@ -174,7 +174,16 @@ pub async fn promote_stream_cache_to_hot_cache( &track_id, None, ); - let _ = enqueue_track_analysis(&app, &server_id, &track_id, &bytes, priority).await; + let format_hint = Some(suffix.to_ascii_lowercase()); + let _ = enqueue_track_analysis( + &app, + &server_id, + &track_id, + &bytes, + format_hint.as_deref(), + priority, + ) + .await; let size = tokio::fs::metadata(&file_path) .await diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index dd6ca52a..2fbd7253 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -137,6 +137,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Cover art: Windows thumbnails, tier fallback, PNG decode, Subsonic coverArt id resolution (PR #878)', 'Analytics: native advanced library backfill coordinator — UI stays responsive on large libraries (PR #881)', 'Analytics: library backfill scan phase/cursor persistence so advanced indexing can finish large libraries (PR #882)', + 'Analytics: Opus waveform/LUFS/enrichment decode via symphonia-adapter-libopus in the analysis pipeline (PR #883)', ], }, {