mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
11974e1438
* feat(analysis): align index settings and per-server strategies Rebuild the local index UX to live under Servers with per-server analytics strategies, and scope analysis queue hints/pruning by playback server so priorities stay isolated across profiles. * feat(analysis): add progress tracking and server analysis deletion functionality Introduce new interfaces for tracking library analysis progress and reporting on server analysis deletions. Implement functions to retrieve analysis progress for a server and to delete all analysis data for a specified server, enhancing the analytics strategy section with real-time progress updates and management capabilities. Update relevant components and localization files to support these features. * feat(server): implement server index key migration and enhance server ID resolution Add functionality to migrate server index keys from legacy IDs to new URL-based keys, improving server ID resolution across the application. Introduce new types and commands for handling server key migrations in both analysis and library contexts. Update relevant functions to utilize the new server ID resolution logic, ensuring consistency and accuracy in server-related operations. * refactor(library): simplify server ID handling in sync progress and idle subscriptions Refactor the library sync progress and idle subscription functions to directly use the payload's server ID without additional mapping. Update related components to resolve server IDs using a new utility function, ensuring consistent server ID resolution across the application. This change enhances code clarity and maintains functionality. * refactor(analytics): rename advanced strategy to aggressive and update descriptions Refactor the AnalyticsStrategySection component to rename the 'advanced' strategy to 'aggressive' for clarity. Update related localization strings to reflect this change, enhancing the user experience by providing clearer descriptions of the analytics strategies. Additionally, remove unused strategy description functions to streamline the code. * fix(audio): update server ID handling in audio progress functions Refactor the audio progress handling to utilize the new `getPlaybackIndexKey` function for server ID resolution. This change ensures that the correct analysis server ID is used when processing audio progress, enhancing the accuracy of playback operations. Additionally, a minor update was made to the analysis cache to include a checkpoint after seeding from bytes. Update the library path in live search to reflect the new database structure. * refactor(analysis): update server ID handling and drop legacy keys Refactor server ID handling across analysis components to utilize scheme-less keys (host + optional path) instead of legacy scheme-based keys. Introduce SQL migrations to drop legacy analysis rows and library entries keyed by scheme URLs. Update relevant functions and tests to ensure consistent server ID resolution and remove references to the legacy '' scope, enhancing clarity and maintainability. * refactor(migration): switch to strategy C dual-db flow Replace destructive server-key migration paths with a blocking inspect/run pipeline that imports into v2 sqlite files, verifies data, then switches active databases with backup safety. Add frontend migration orchestration and post-switch key rewrites while preserving existing user settings behavior. * fix(migration): harden runtime db switch and startup gate Switch database promotion through live runtime store/cache connection swaps so migration cannot leave writers on old sqlite inodes, and tighten startup gating to block initialization until migration completes. Also fix empty-bucket warning detection and set the done flag only after a post-run inspect confirms no pending legacy rows. * feat(migration): enhance migration reporting with skipped server rows tracking Add new fields to migration interfaces and reports to track skipped rows for removed servers. Update relevant components to display warnings and log messages when such rows are encountered during migration processes, improving visibility and user awareness of migration status. * fix(migration): avoid startup blocking modal on no-op runs Keep migration gate completed by default after successful runs and perform done-flag inspections without forcing a blocking phase, so normal app startup no longer flashes migration preparation when no migration is needed. * fix(migration): enforce startup precheck and purge unknown rows Prevent stale done-flag bypass by starting migration state in idle and gating completion on orchestrator precheck, and delete unknown removed-server rows from v2 databases before switch so skipped rows are not carried into the new active DB. * fix(migration): block UI during done-flag precheck Set inspecting phase before the first migration inspection and treat idle as blocking in the migration gate, so startup precheck cannot render the app before migration status is confirmed. * fix(migration): hide precheck modal when no migration is needed Keep startup precheck in a non-blocking idle phase and show the migration modal only after inspect confirms real migration work, removing the recurring half-second migration flash for already-migrated users. * fix(migration): cleanup legacy db files after path migration Always remove legacy analysis and library sqlite files (including wal/shm sidecars) when the new database paths are active, so old-path artifacts from previous builds do not linger after migration. * docs(changelog): add PR #864 release notes and contributor credit Document the full index-key rebuild scope for 1.47.0 and add the corresponding settings credit entry for PR #864. * test(analysis): raise hot-path coverage for analysis cache Add focused unit tests for analysis cache compute/store hot paths and edge branches so coverage regressions are caught before CI. Make AppHandle entrypoints runtime-generic and enable tauri test utilities in dev dependencies to cover no-cache and registered-cache execute paths. * fix(migration): make rebind pass resilient to foreign key ordering Run library and analysis server_id rebind operations inside a foreign-key-disabled transaction and validate with PRAGMA foreign_key_check after commit, so migrations from older databases do not fail on transient FK ordering during bulk updates. * feat(backup): add dual-database backup flow and blocking UX Extend backup/export and restore flows to handle library databases with unified archive detection and asynchronous backend execution. Improve backup UI with a global blocking modal and clearer localized copy so long operations do not look like app hangs. * docs(changelog): add PR #864 backup notes and contributor credit Update 1.47.0 release notes with backup/restore UX and archive-flow entries for PR #864, and add the matching settings credits contribution line for cucadmuh. * docs(changelog): sort 1.47.0 entries from old to new Reorder Added, Changed, and Fixed subsections in the 1.47.0 changelog so entries follow chronological PR order inside each block. * fix(playback): align offline/hot cache lookup with indexKey scope Use a canonical playback cache key based on indexKey with legacy UUID fallback so migrated offline and hot-cache entries are still resolved on normal play, resume, queue-undo, and prefetch paths. Refresh PR #864 changelog/credits text to reflect the full migration and backup scope.
1239 lines
46 KiB
Rust
1239 lines
46 KiB
Rust
use std::io::Cursor;
|
||
use std::time::Instant;
|
||
|
||
use ebur128::{EbuR128, Mode as Ebur128Mode};
|
||
use symphonia::core::audio::SampleBuffer;
|
||
use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL};
|
||
use symphonia::core::errors::Error as SymphoniaError;
|
||
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
|
||
use symphonia::core::io::MediaSourceStream;
|
||
use symphonia::core::meta::MetadataOptions;
|
||
use symphonia::core::probe::Hint;
|
||
use symphonia::core::units::Time;
|
||
use tauri::{Manager, Runtime};
|
||
|
||
use crate::analysis_perf::AnalysisSeedTimings;
|
||
|
||
use super::store::{now_unix_ts, AnalysisCache, LoudnessEntry, TrackKey, WaveformEntry};
|
||
|
||
pub fn recommended_gain_for_target(integrated_lufs: f64, true_peak: f64, target_lufs: f64) -> f64 {
|
||
let mut recommended_gain_db = target_lufs - integrated_lufs;
|
||
if true_peak > 0.0 {
|
||
let true_peak_dbtp = 20.0 * true_peak.log10();
|
||
let max_gain_db = -1.0 - true_peak_dbtp;
|
||
if recommended_gain_db > max_gain_db {
|
||
recommended_gain_db = max_gain_db;
|
||
}
|
||
}
|
||
recommended_gain_db.clamp(-24.0, 24.0)
|
||
}
|
||
|
||
/// Result of [`seed_from_bytes_execute`] / CPU seed queue: callers use it to avoid redundant UI events.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum SeedFromBytesOutcome {
|
||
/// Wrote waveform (and loudness when PCM decode succeeded).
|
||
Upserted,
|
||
/// Same `track_id` + `md5_16kb` already had a non-empty waveform for this algo version.
|
||
SkippedWaveformCacheHit,
|
||
/// `AnalysisCache` was not registered on the app handle.
|
||
SkippedNoAnalysisCache,
|
||
}
|
||
|
||
/// Full Symphonia + (optional) EBU decode for waveform + loudness. Call only from the
|
||
/// single CPU-seed worker in `lib.rs` (`spawn_blocking`) so at most one heavy decode runs.
|
||
pub fn seed_from_bytes_execute<R: Runtime>(
|
||
app: &tauri::AppHandle<R>,
|
||
server_id: &str,
|
||
track_id: &str,
|
||
bytes: &[u8],
|
||
) -> Result<(SeedFromBytesOutcome, AnalysisSeedTimings), String> {
|
||
let seed_started = Instant::now();
|
||
let Some(cache) = app.try_state::<AnalysisCache>() else {
|
||
crate::app_deprintln!(
|
||
"[analysis][waveform] build skip track_id={} reason=no_analysis_cache bytes={}",
|
||
track_id,
|
||
bytes.len()
|
||
);
|
||
return Ok((
|
||
SeedFromBytesOutcome::SkippedNoAnalysisCache,
|
||
AnalysisSeedTimings::default(),
|
||
));
|
||
};
|
||
let (outcome, md5_16kb) = seed_from_bytes_into_cache(&cache, server_id, track_id, bytes)?;
|
||
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
|
||
// as `track.content_hash` via the registered sink. Decoupled from
|
||
// psysonic-library through the psysonic-core port; a no-op when the library
|
||
// has no row for this (server_id, track_id). Skipped when no server is known.
|
||
if !server_id.is_empty()
|
||
&& matches!(
|
||
outcome,
|
||
SeedFromBytesOutcome::Upserted | SeedFromBytesOutcome::SkippedWaveformCacheHit
|
||
)
|
||
{
|
||
if let Some(sink) = app.try_state::<psysonic_core::ports::ContentHashSink>() {
|
||
sink.record_content_hash(server_id, track_id, &md5_16kb);
|
||
}
|
||
}
|
||
let bpm_ms = if !server_id.is_empty() {
|
||
let bpm_started = Instant::now();
|
||
let _ = crate::track_enrichment::run_track_enrichment_if_needed(
|
||
app,
|
||
server_id,
|
||
track_id,
|
||
bytes,
|
||
);
|
||
bpm_started.elapsed().as_millis() as u64
|
||
} else {
|
||
0
|
||
};
|
||
Ok((
|
||
outcome,
|
||
AnalysisSeedTimings { seed_ms, bpm_ms },
|
||
))
|
||
}
|
||
|
||
/// AppHandle-free entry point for [`seed_from_bytes_execute`]: takes the cache
|
||
/// directly, runs the same Symphonia → waveform → EBU R128 pipeline, and
|
||
/// upserts the rows. Called from `seed_from_bytes_execute` in production and
|
||
/// from tests against an in-memory cache.
|
||
/// Returns the outcome plus the computed `md5_16kb` (the content fingerprint),
|
||
/// so the AppHandle-aware caller can bridge it to the library `content_hash`
|
||
/// (E2) without re-reading the bytes.
|
||
pub fn seed_from_bytes_into_cache(
|
||
cache: &AnalysisCache,
|
||
server_id: &str,
|
||
track_id: &str,
|
||
bytes: &[u8],
|
||
) -> Result<(SeedFromBytesOutcome, String), String> {
|
||
let started = Instant::now();
|
||
// Write under the playback server's scope.
|
||
let key = TrackKey {
|
||
server_id: server_id.to_string(),
|
||
track_id: track_id.to_string(),
|
||
md5_16kb: md5_first_16kb(bytes),
|
||
};
|
||
let coverage = cache.content_cache_coverage(server_id, track_id, &key.md5_16kb)?;
|
||
if coverage.complete() {
|
||
crate::app_deprintln!(
|
||
"[analysis][waveform] build skip track_id={} reason=waveform_cache_hit md5_16kb={} elapsed_ms={}",
|
||
track_id,
|
||
key.md5_16kb,
|
||
started.elapsed().as_millis()
|
||
);
|
||
return Ok((SeedFromBytesOutcome::SkippedWaveformCacheHit, key.md5_16kb.clone()));
|
||
}
|
||
if coverage.has_waveform && !coverage.has_loudness {
|
||
crate::app_deprintln!(
|
||
"[analysis][waveform] waveform cache hit but loudness missing — full re-analysis track_id={} md5_16kb={}",
|
||
track_id,
|
||
key.md5_16kb
|
||
);
|
||
}
|
||
let mib = bytes.len() as f64 / (1024.0 * 1024.0);
|
||
crate::app_deprintln!(
|
||
"[analysis] full-track analysis start track_id={} input_mib={:.2} md5_16kb={}",
|
||
track_id,
|
||
mib,
|
||
key.md5_16kb
|
||
);
|
||
crate::app_deprintln!(
|
||
"[analysis] full-track analysis work: Symphonia decodes the entire buffer twice (frame timeline, then PCM peak bins), then EBU R128 integrated loudness + true-peak when that succeeds — CPU-bound; large lossless files often take minutes"
|
||
);
|
||
|
||
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) {
|
||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs, bins)) => {
|
||
(
|
||
bins,
|
||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)),
|
||
true,
|
||
)
|
||
}
|
||
None => (derive_waveform_bins(bytes, 500), None, false),
|
||
};
|
||
let bins_len = wf_bins.len();
|
||
let waveform = WaveformEntry {
|
||
bins: wf_bins,
|
||
bin_count: 500,
|
||
is_partial: false,
|
||
known_until_sec: 0.0,
|
||
duration_sec: 0.0,
|
||
updated_at: now_unix_ts(),
|
||
};
|
||
cache.upsert_waveform(&key, &waveform)?;
|
||
|
||
if let Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs)) = loudness_opt {
|
||
let loudness = LoudnessEntry {
|
||
integrated_lufs,
|
||
true_peak,
|
||
recommended_gain_db,
|
||
target_lufs,
|
||
updated_at: now_unix_ts(),
|
||
};
|
||
cache.upsert_loudness(&key, &loudness)?;
|
||
}
|
||
|
||
cache.touch_track_status(&key, "ready")?;
|
||
let _ = cache.checkpoint_wal("analysis.seed");
|
||
Ok((used_pcm_decode, bins_len))
|
||
})();
|
||
|
||
let elapsed_ms = started.elapsed().as_millis();
|
||
match &build {
|
||
Ok((used_pcm_decode, bins_len)) => {
|
||
crate::app_deprintln!(
|
||
"[analysis] full-track analysis done track_id={} elapsed_ms={} decode_path={} bins_len={} ebu_loudness_cached={}",
|
||
track_id,
|
||
elapsed_ms,
|
||
if *used_pcm_decode {
|
||
"pcm_ebur128"
|
||
} else {
|
||
"byte_envelope_no_ebu"
|
||
},
|
||
bins_len,
|
||
*used_pcm_decode
|
||
);
|
||
}
|
||
Err(e) => {
|
||
crate::app_deprintln!(
|
||
"[analysis] full-track analysis failed track_id={} elapsed_ms={} err={}",
|
||
track_id,
|
||
elapsed_ms,
|
||
e
|
||
);
|
||
}
|
||
}
|
||
|
||
match build {
|
||
Ok(_) => Ok((SeedFromBytesOutcome::Upserted, key.md5_16kb.clone())),
|
||
Err(e) => Err(e),
|
||
}
|
||
}
|
||
|
||
pub fn md5_first_16kb(bytes: &[u8]) -> String {
|
||
let n = bytes.len().min(16 * 1024);
|
||
format!("{:x}", md5::compute(&bytes[..n]))
|
||
}
|
||
|
||
fn derive_waveform_bins(bytes: &[u8], bin_count: usize) -> Vec<u8> {
|
||
if bin_count == 0 || bytes.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
let mut peak_half = vec![0u8; bin_count];
|
||
for (i, slot) in peak_half.iter_mut().enumerate() {
|
||
let start = i * bytes.len() / bin_count;
|
||
let end = ((i + 1) * bytes.len() / bin_count).max(start + 1).min(bytes.len());
|
||
let mut peak: u8 = 0;
|
||
for &b in &bytes[start..end] {
|
||
let centered = b.abs_diff(128);
|
||
if centered > peak {
|
||
peak = centered;
|
||
}
|
||
}
|
||
*slot = ((peak as f32 / 127.0).sqrt().clamp(0.0, 1.0) * 255.0) as u8;
|
||
}
|
||
let mut out = peak_half.clone();
|
||
out.extend_from_slice(&peak_half);
|
||
out
|
||
}
|
||
|
||
struct PcmScanResult {
|
||
bins: Vec<u8>,
|
||
loudness: Option<(f64, f64, f64, f64)>,
|
||
}
|
||
|
||
/// Loudness (EBU R128) plus PCM waveform bins in one decode pass after a frame count.
|
||
fn analyze_loudness_and_waveform(
|
||
bytes: &[u8],
|
||
target_lufs: f64,
|
||
bin_count: usize,
|
||
) -> Option<(f64, f64, f64, f64, Vec<u8>)> {
|
||
if bytes.is_empty() || bin_count == 0 {
|
||
return None;
|
||
}
|
||
let (decoded_frames, timeline_hint) = count_mono_frames_from_audio_bytes(bytes)?;
|
||
if decoded_frames == 0 {
|
||
return None;
|
||
}
|
||
let scanned = decode_scan_pcm(bytes, bin_count, decoded_frames, timeline_hint, Some(target_lufs))?;
|
||
let (i, t, r, tgt) = scanned.loudness?;
|
||
Some((i, t, r, tgt, scanned.bins))
|
||
}
|
||
|
||
/// One-shot Symphonia setup: probe the byte buffer, pick a usable track, and
|
||
/// build a decoder for it. `timeline_hint` carries `codec_params.n_frames`
|
||
/// when the container reports total track length.
|
||
struct DecodeSession {
|
||
format: Box<dyn FormatReader>,
|
||
decoder: Box<dyn Decoder>,
|
||
track_id: u32,
|
||
timeline_hint: Option<u64>,
|
||
}
|
||
|
||
fn open_decode_session(bytes: &[u8]) -> Option<DecodeSession> {
|
||
let source = Box::new(Cursor::new(bytes.to_vec()));
|
||
let mss = MediaSourceStream::new(source, Default::default());
|
||
let hint = Hint::new();
|
||
let probed = symphonia::default::get_probe()
|
||
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
|
||
.ok()?;
|
||
let format = probed.format;
|
||
let track = format
|
||
.default_track()
|
||
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
|
||
.or_else(|| {
|
||
format.tracks().iter().find(|t| {
|
||
t.codec_params.codec != CODEC_TYPE_NULL
|
||
&& t.codec_params.sample_rate.is_some()
|
||
&& t.codec_params.channels.is_some()
|
||
})
|
||
})
|
||
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
|
||
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()) {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
|
||
return None;
|
||
}
|
||
};
|
||
Some(DecodeSession { format, decoder, track_id, timeline_hint })
|
||
}
|
||
|
||
/// Returns `(decoded_mono_frames, container_timeline_frames)` where the second is
|
||
/// `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<u64>)> {
|
||
let DecodeSession { mut format, mut decoder, track_id, timeline_hint } =
|
||
open_decode_session(bytes)?;
|
||
|
||
let mut total: u64 = 0;
|
||
let mut loop_i: u32 = 0;
|
||
while let Ok(packet) = format.next_packet() {
|
||
if packet.track_id() != track_id {
|
||
continue;
|
||
}
|
||
let decoded = match decoder.decode(&packet) {
|
||
Ok(buf) => buf,
|
||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||
Err(SymphoniaError::ResetRequired) => break,
|
||
Err(_) => break,
|
||
};
|
||
let spec = *decoded.spec();
|
||
let n_ch = spec.channels.count();
|
||
if n_ch == 0 {
|
||
continue;
|
||
}
|
||
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||
samples.copy_interleaved_ref(decoded);
|
||
let n = samples.samples().len();
|
||
if n < n_ch || !n.is_multiple_of(n_ch) {
|
||
continue;
|
||
}
|
||
total += (n / n_ch) as u64;
|
||
loop_i = loop_i.wrapping_add(1);
|
||
if loop_i.is_multiple_of(128) {
|
||
std::thread::yield_now();
|
||
}
|
||
}
|
||
if total == 0 {
|
||
None
|
||
} else {
|
||
Some((total, timeline_hint))
|
||
}
|
||
}
|
||
|
||
fn normalize_peak_bins(bin_max: &[f32]) -> Vec<u8> {
|
||
let bin_count = bin_max.len();
|
||
if bin_count == 0 {
|
||
return Vec::new();
|
||
}
|
||
let mut sorted: Vec<f32> = bin_max.to_vec();
|
||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||
let p5 = sorted[(sorted.len() * 5 / 100).min(sorted.len().saturating_sub(1))];
|
||
let p99 = sorted[(sorted.len() * 99 / 100).min(sorted.len().saturating_sub(1))];
|
||
let range = (p99 - p5).max(1e-8);
|
||
let mut out = vec![0u8; bin_count];
|
||
for i in 0..bin_count {
|
||
let t = ((bin_max[i] - p5) / range).clamp(0.0, 1.0);
|
||
let shaped = t.powf(0.52);
|
||
out[i] = (8.0 + shaped * 247.0).min(255.0) as u8;
|
||
}
|
||
out
|
||
}
|
||
|
||
fn decode_scan_pcm(
|
||
bytes: &[u8],
|
||
bin_count: usize,
|
||
decoded_frames: u64,
|
||
timeline_hint: Option<u64>,
|
||
loudness_target_lufs: Option<f64>,
|
||
) -> Option<PcmScanResult> {
|
||
let DecodeSession { mut format, mut decoder, track_id, .. } = open_decode_session(bytes)?;
|
||
|
||
let mut bin_max = vec![0.0f32; bin_count];
|
||
let mut bin_sum = vec![0.0f32; bin_count];
|
||
let mut bin_n = vec![0u32; bin_count];
|
||
let mut ebu: Option<EbuR128> = None;
|
||
let mut ebu_channels: u32 = 0;
|
||
let mut sample_peak_abs = 0.0_f64;
|
||
let mut fed_any_frames = false;
|
||
let mut sample_idx: u64 = 0;
|
||
let mut loop_i: u32 = 0;
|
||
// Bin mapping must use the decoded mono sample count. When the container
|
||
// reports `n_frames` **larger** than what we actually decoded (bad VBR tags,
|
||
// wrong duration in headers) but the buffer is already the full file — all
|
||
// CPU-seed paths pass a complete artifact — using `max(n_frames, decoded)`
|
||
// squashes the entire waveform into the leading bins ("only the start").
|
||
if let Some(n) = timeline_hint {
|
||
if n > decoded_frames {
|
||
crate::app_deprintln!(
|
||
"[analysis][waveform] bin_grid: ignore container n_frames={} (> decoded {}) — map bins to decoded length",
|
||
n,
|
||
decoded_frames
|
||
);
|
||
}
|
||
}
|
||
let bin_grid_frames = decoded_frames.max(1);
|
||
|
||
while let Ok(packet) = format.next_packet() {
|
||
if packet.track_id() != track_id {
|
||
continue;
|
||
}
|
||
let decoded = match decoder.decode(&packet) {
|
||
Ok(buf) => buf,
|
||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||
Err(SymphoniaError::ResetRequired) => break,
|
||
Err(_) => break,
|
||
};
|
||
|
||
let spec = *decoded.spec();
|
||
let n_ch = spec.channels.count();
|
||
if n_ch == 0 {
|
||
continue;
|
||
}
|
||
|
||
if loudness_target_lufs.is_some() && ebu.is_none() {
|
||
let ch = spec.channels.count() as u32;
|
||
let sr = spec.rate;
|
||
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
|
||
Ok(v) => {
|
||
ebu = Some(v);
|
||
ebu_channels = ch;
|
||
}
|
||
Err(e) => {
|
||
crate::app_deprintln!(
|
||
"[analysis] EbuR128 init failed: channels={} sample_rate={} err={}",
|
||
ch,
|
||
sr,
|
||
e
|
||
);
|
||
return None;
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||
samples.copy_interleaved_ref(decoded);
|
||
let slice = samples.samples();
|
||
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
|
||
continue;
|
||
}
|
||
let frames = slice.len() / n_ch;
|
||
|
||
for f in 0..frames {
|
||
let base = f * n_ch;
|
||
let mut acc = 0.0f32;
|
||
for c in 0..n_ch {
|
||
acc += slice[base + c];
|
||
}
|
||
let mono = acc / (n_ch as f32);
|
||
let mag = mono.abs();
|
||
if mag.is_finite() {
|
||
let bin = ((sample_idx * bin_count as u64) / bin_grid_frames) as usize;
|
||
let bin = bin.min(bin_count.saturating_sub(1));
|
||
bin_max[bin] = bin_max[bin].max(mag);
|
||
bin_sum[bin] += mag;
|
||
bin_n[bin] = bin_n[bin].saturating_add(1);
|
||
}
|
||
for c in 0..n_ch {
|
||
let v = (slice[base + c] as f64).abs();
|
||
if v.is_finite() && v > sample_peak_abs {
|
||
sample_peak_abs = v;
|
||
}
|
||
}
|
||
sample_idx += 1;
|
||
}
|
||
|
||
if loudness_target_lufs.is_some() {
|
||
if let Some(e) = ebu.as_mut() {
|
||
match e.add_frames_f32(samples.samples()) {
|
||
Ok(_) => fed_any_frames = true,
|
||
Err(err) => {
|
||
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err);
|
||
return None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
loop_i = loop_i.wrapping_add(1);
|
||
if loop_i.is_multiple_of(128) {
|
||
std::thread::yield_now();
|
||
}
|
||
}
|
||
|
||
let mut bin_mean = vec![0.0f32; bin_count];
|
||
for i in 0..bin_count {
|
||
if bin_n[i] > 0 {
|
||
bin_mean[i] = bin_sum[i] / (bin_n[i] as f32);
|
||
}
|
||
}
|
||
let peak_u8 = normalize_peak_bins(&bin_max);
|
||
let mean_u8 = normalize_peak_bins(&bin_mean);
|
||
let mut bins = Vec::with_capacity(peak_u8.len().saturating_mul(2));
|
||
bins.extend_from_slice(&peak_u8);
|
||
bins.extend_from_slice(&mean_u8);
|
||
|
||
let loudness = if let Some(target_lufs) = loudness_target_lufs {
|
||
if !fed_any_frames {
|
||
crate::app_deprintln!("[analysis] loudness failed: no decoded frames");
|
||
return None;
|
||
}
|
||
let Some(ebu) = ebu else {
|
||
crate::app_deprintln!("[analysis] loudness failed: ebu not initialized");
|
||
return None;
|
||
};
|
||
let integrated_lufs = match ebu.loudness_global() {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
crate::app_deprintln!("[analysis] loudness_global failed: {}", e);
|
||
return None;
|
||
}
|
||
};
|
||
if !integrated_lufs.is_finite() {
|
||
crate::app_deprintln!("[analysis] loudness failed: integrated_lufs not finite");
|
||
return None;
|
||
}
|
||
let mut true_peak = 0.0_f64;
|
||
let mut true_peak_ok = true;
|
||
for ch in 0..ebu_channels {
|
||
match ebu.true_peak(ch) {
|
||
Ok(v) if v.is_finite() && v > true_peak => true_peak = v,
|
||
Ok(_) => {}
|
||
Err(e) => {
|
||
true_peak_ok = false;
|
||
crate::app_deprintln!("[analysis] true_peak unavailable: {}", e);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if !true_peak_ok {
|
||
true_peak = sample_peak_abs;
|
||
}
|
||
let recommended_gain_db =
|
||
recommended_gain_for_target(integrated_lufs, true_peak, target_lufs);
|
||
Some((integrated_lufs, true_peak, recommended_gain_db, target_lufs))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
Some(PcmScanResult { bins, loudness })
|
||
}
|
||
|
||
/// PCM window for short MIR-style analysis (typically 60 s from track center).
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct PcmAnalysisWindow {
|
||
pub start_sec: f64,
|
||
pub duration_sec: f64,
|
||
}
|
||
|
||
/// Pick a centered analysis window, or the full track when shorter than `window_sec`.
|
||
pub fn analysis_pcm_window(total_duration_sec: f64, window_sec: f64) -> PcmAnalysisWindow {
|
||
let total = total_duration_sec.max(0.0);
|
||
let window = window_sec.max(0.1);
|
||
if total <= window || !total.is_finite() {
|
||
return PcmAnalysisWindow {
|
||
start_sec: 0.0,
|
||
duration_sec: if total > 0.0 { total } else { window },
|
||
};
|
||
}
|
||
let start = ((total - window) / 2.0).max(0.0);
|
||
PcmAnalysisWindow {
|
||
start_sec: start,
|
||
duration_sec: window,
|
||
}
|
||
}
|
||
|
||
/// Best-effort container duration from codec metadata (seconds).
|
||
pub fn audio_duration_from_bytes(bytes: &[u8]) -> Option<f64> {
|
||
let session = open_decode_session(bytes)?;
|
||
let sample_rate = session
|
||
.format
|
||
.default_track()
|
||
.or_else(|| session.format.tracks().first())
|
||
.and_then(|t| t.codec_params.sample_rate)
|
||
.filter(|&sr| sr > 0)?;
|
||
let frames = session.timeline_hint?;
|
||
Some(frames as f64 / sample_rate as f64)
|
||
}
|
||
|
||
/// Decode mono PCM for a time window. Seeks when `start_sec > 0`.
|
||
pub fn decode_mono_pcm_window(
|
||
bytes: &[u8],
|
||
start_sec: f64,
|
||
window_sec: f64,
|
||
) -> Result<(Vec<f32>, f32), String> {
|
||
if bytes.is_empty() {
|
||
return Err("empty audio buffer".to_string());
|
||
}
|
||
let DecodeSession {
|
||
mut format,
|
||
mut decoder,
|
||
track_id,
|
||
..
|
||
} = open_decode_session(bytes).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();
|
||
format
|
||
.seek(
|
||
SeekMode::Accurate,
|
||
SeekTo::Time {
|
||
time,
|
||
track_id: Some(track_id),
|
||
},
|
||
)
|
||
.map_err(|e| format!("pcm window seek failed: {e}"))?;
|
||
}
|
||
|
||
decode_mono_pcm_from_session(&mut format, &mut decoder, track_id, Some(window_sec))
|
||
}
|
||
|
||
/// Decode audio bytes to mono f32 PCM, optionally capped at `max_seconds`.
|
||
pub fn decode_mono_pcm_limited(
|
||
bytes: &[u8],
|
||
max_seconds: Option<f64>,
|
||
) -> Result<(Vec<f32>, f32), String> {
|
||
if bytes.is_empty() {
|
||
return Err("empty audio buffer".to_string());
|
||
}
|
||
let DecodeSession {
|
||
mut format,
|
||
mut decoder,
|
||
track_id,
|
||
..
|
||
} = open_decode_session(bytes).ok_or_else(|| "failed to open audio decode session".to_string())?;
|
||
decode_mono_pcm_from_session(&mut format, &mut decoder, track_id, max_seconds)
|
||
}
|
||
|
||
fn decode_mono_pcm_from_session(
|
||
format: &mut Box<dyn FormatReader>,
|
||
decoder: &mut Box<dyn Decoder>,
|
||
track_id: u32,
|
||
max_seconds: Option<f64>,
|
||
) -> Result<(Vec<f32>, f32), String> {
|
||
let mut mono = Vec::new();
|
||
let mut sample_rate = 0_f32;
|
||
let mut max_frames: Option<u64> = None;
|
||
let mut loop_i: u32 = 0;
|
||
|
||
while let Ok(packet) = format.next_packet() {
|
||
if packet.track_id() != track_id {
|
||
continue;
|
||
}
|
||
let decoded = match decoder.decode(&packet) {
|
||
Ok(buf) => buf,
|
||
Err(SymphoniaError::DecodeError(_)) => continue,
|
||
Err(SymphoniaError::ResetRequired) => break,
|
||
Err(_) => break,
|
||
};
|
||
|
||
let spec = *decoded.spec();
|
||
let n_ch = spec.channels.count();
|
||
if n_ch == 0 {
|
||
continue;
|
||
}
|
||
if sample_rate <= 0.0 {
|
||
sample_rate = spec.rate as f32;
|
||
if sample_rate <= 0.0 {
|
||
return Err("invalid sample rate".to_string());
|
||
}
|
||
max_frames = max_seconds.and_then(|sec| {
|
||
if sec.is_finite() && sec > 0.0 {
|
||
Some((sec * sample_rate as f64).max(1.0) as u64)
|
||
} else {
|
||
None
|
||
}
|
||
});
|
||
}
|
||
|
||
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||
samples.copy_interleaved_ref(decoded);
|
||
let slice = samples.samples();
|
||
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
|
||
continue;
|
||
}
|
||
let frames = slice.len() / n_ch;
|
||
for f in 0..frames {
|
||
if let Some(limit) = max_frames {
|
||
if mono.len() as u64 >= limit {
|
||
break;
|
||
}
|
||
}
|
||
let base = f * n_ch;
|
||
let mut acc = 0.0_f32;
|
||
for c in 0..n_ch {
|
||
acc += slice[base + c];
|
||
}
|
||
mono.push(acc / (n_ch as f32));
|
||
}
|
||
if max_frames.is_some_and(|limit| mono.len() as u64 >= limit) {
|
||
break;
|
||
}
|
||
|
||
loop_i = loop_i.wrapping_add(1);
|
||
if loop_i.is_multiple_of(128) {
|
||
std::thread::yield_now();
|
||
}
|
||
}
|
||
|
||
if mono.is_empty() {
|
||
return Err("no PCM frames decoded".to_string());
|
||
}
|
||
Ok((mono, sample_rate))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn approx_f64(a: f64, b: f64, eps: f64) {
|
||
assert!((a - b).abs() < eps, "expected {b}, got {a}");
|
||
}
|
||
|
||
// ── recommended_gain_for_target ───────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn recommended_gain_is_target_minus_integrated_when_no_peak() {
|
||
approx_f64(recommended_gain_for_target(-14.0, 0.0, -10.0), 4.0, 1e-9);
|
||
approx_f64(recommended_gain_for_target(-23.0, 0.0, -14.0), 9.0, 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn recommended_gain_caps_to_avoid_clipping_when_true_peak_is_high() {
|
||
// true_peak = 1.0 (0 dBTP) → max_gain_db = -1.0 - 0 = -1.0
|
||
// target - integrated = -10 - (-14) = 4.0, but capped to -1.0.
|
||
let g = recommended_gain_for_target(-14.0, 1.0, -10.0);
|
||
approx_f64(g, -1.0, 1e-6);
|
||
}
|
||
|
||
#[test]
|
||
fn recommended_gain_clamps_to_plus_minus_24() {
|
||
let huge_up = recommended_gain_for_target(-100.0, 0.0, 100.0);
|
||
let huge_down = recommended_gain_for_target(100.0, 0.0, -100.0);
|
||
assert_eq!(huge_up, 24.0);
|
||
assert_eq!(huge_down, -24.0);
|
||
}
|
||
|
||
#[test]
|
||
fn analysis_pcm_window_uses_center_for_long_tracks() {
|
||
let w = analysis_pcm_window(180.0, 60.0);
|
||
assert!((w.start_sec - 60.0).abs() < 1e-9);
|
||
assert!((w.duration_sec - 60.0).abs() < 1e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn analysis_pcm_window_uses_full_track_when_short() {
|
||
let w = analysis_pcm_window(45.0, 60.0);
|
||
assert_eq!(w.start_sec, 0.0);
|
||
assert!((w.duration_sec - 45.0).abs() < 1e-9);
|
||
}
|
||
|
||
// ── md5_first_16kb ────────────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn md5_of_empty_bytes_matches_md5_empty() {
|
||
// md5 of "" = d41d8cd98f00b204e9800998ecf8427e
|
||
assert_eq!(md5_first_16kb(&[]), "d41d8cd98f00b204e9800998ecf8427e");
|
||
}
|
||
|
||
#[test]
|
||
fn md5_uses_full_data_when_under_16kb() {
|
||
let data = b"hello world";
|
||
let direct = format!("{:x}", md5::compute(data));
|
||
assert_eq!(md5_first_16kb(data), direct);
|
||
}
|
||
|
||
#[test]
|
||
fn md5_truncates_to_first_16kb() {
|
||
let mut data = vec![0xAAu8; 16 * 1024];
|
||
let prefix_only = format!("{:x}", md5::compute(&data));
|
||
// Append distinguishing bytes past 16 KB; the digest must not change.
|
||
data.extend_from_slice(b"---should be ignored by md5_first_16kb---");
|
||
assert_eq!(md5_first_16kb(&data), prefix_only);
|
||
}
|
||
|
||
// ── derive_waveform_bins ──────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn derive_waveform_returns_empty_for_zero_bin_count() {
|
||
assert_eq!(derive_waveform_bins(&[1u8, 2, 3, 4], 0), Vec::<u8>::new());
|
||
}
|
||
|
||
#[test]
|
||
fn derive_waveform_returns_empty_for_empty_bytes() {
|
||
assert_eq!(derive_waveform_bins(&[], 4), Vec::<u8>::new());
|
||
}
|
||
|
||
#[test]
|
||
fn derive_waveform_silence_at_midpoint_yields_zero_bins() {
|
||
// 128 is the unsigned-PCM midpoint: abs_diff(128) == 0 for every sample.
|
||
let silence = vec![128u8; 64];
|
||
let out = derive_waveform_bins(&silence, 8);
|
||
assert!(out.iter().all(|&b| b == 0), "silence must produce all-zero bins, got {out:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn derive_waveform_doubles_the_bin_buffer() {
|
||
// The function returns peak_half twice (peak followed by mean-abs placeholder).
|
||
let bytes = vec![0u8; 32];
|
||
let out = derive_waveform_bins(&bytes, 4);
|
||
assert_eq!(out.len(), 8, "output must be 2 * bin_count");
|
||
assert_eq!(&out[..4], &out[4..]);
|
||
}
|
||
|
||
#[test]
|
||
fn derive_waveform_reaches_max_for_extreme_amplitude() {
|
||
// Extreme deviation from 128 → centered = 127 (when input is 0 or 255).
|
||
// (127/127)^0.5 = 1.0 → 255 in u8.
|
||
let bytes = vec![0u8; 16];
|
||
let out = derive_waveform_bins(&bytes, 4);
|
||
assert!(out.iter().all(|&b| b == 255), "max amplitude must yield 255 bins");
|
||
}
|
||
|
||
// ── normalize_peak_bins ───────────────────────────────────────────────────
|
||
|
||
#[test]
|
||
fn normalize_peak_returns_empty_for_empty_input() {
|
||
assert_eq!(normalize_peak_bins(&[]), Vec::<u8>::new());
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_peak_uniform_input_collapses_to_base_offset() {
|
||
// p5 == p99 → range collapses to 1e-8 floor; t = (x - p5)/range = 0 for all.
|
||
// shaped = 0; out = 8 (base offset).
|
||
let bins = vec![0.5f32; 16];
|
||
let out = normalize_peak_bins(&bins);
|
||
assert_eq!(out.len(), 16);
|
||
assert!(out.iter().all(|&b| b == 8), "got {out:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn normalize_peak_monotonic_input_yields_increasing_output() {
|
||
// Strictly increasing input must produce non-decreasing output.
|
||
let bins: Vec<f32> = (0..100).map(|i| i as f32 / 100.0).collect();
|
||
let out = normalize_peak_bins(&bins);
|
||
for win in out.windows(2) {
|
||
assert!(win[0] <= win[1], "non-monotonic output around {:?}", win);
|
||
}
|
||
// Output range ⊆ [8, 255].
|
||
assert!(out.iter().all(|&b| (8..=255).contains(&b)));
|
||
}
|
||
|
||
// ── End-to-end: WAV decode → waveform + loudness pipeline ────────────────
|
||
//
|
||
// Symphonia's PCM/WAV decoder is the cheapest format we can feed end-to-end
|
||
// without committing a binary fixture. Every test here generates a tiny
|
||
// mono 16-bit-PCM WAV (~150 KB for 1.5 s @ 44.1 kHz) at runtime, hands the
|
||
// bytes to the real seed pipeline, and asserts on the cached rows.
|
||
|
||
/// Build a mono signed-16-bit-PCM WAV from a sample buffer at `sample_rate`.
|
||
/// Produces a buffer ready to be probed by Symphonia's WAV format reader.
|
||
fn build_mono_pcm16_wav(samples: &[i16], sample_rate: u32) -> Vec<u8> {
|
||
let num_channels: u16 = 1;
|
||
let bits_per_sample: u16 = 16;
|
||
let byte_rate = sample_rate * (bits_per_sample as u32 / 8) * num_channels as u32;
|
||
let block_align = num_channels * (bits_per_sample / 8);
|
||
let data_size = (samples.len() * 2) as u32;
|
||
let riff_size = 36 + data_size;
|
||
|
||
let mut out = Vec::with_capacity(44 + data_size as usize);
|
||
out.extend_from_slice(b"RIFF");
|
||
out.extend_from_slice(&riff_size.to_le_bytes());
|
||
out.extend_from_slice(b"WAVE");
|
||
// fmt chunk
|
||
out.extend_from_slice(b"fmt ");
|
||
out.extend_from_slice(&16u32.to_le_bytes()); // sub-chunk size
|
||
out.extend_from_slice(&1u16.to_le_bytes()); // PCM format tag
|
||
out.extend_from_slice(&num_channels.to_le_bytes());
|
||
out.extend_from_slice(&sample_rate.to_le_bytes());
|
||
out.extend_from_slice(&byte_rate.to_le_bytes());
|
||
out.extend_from_slice(&block_align.to_le_bytes());
|
||
out.extend_from_slice(&bits_per_sample.to_le_bytes());
|
||
// data chunk
|
||
out.extend_from_slice(b"data");
|
||
out.extend_from_slice(&data_size.to_le_bytes());
|
||
for s in samples {
|
||
out.extend_from_slice(&s.to_le_bytes());
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Generate a 1-second 440 Hz sine wave at -6 dBFS as a Vec<i16>.
|
||
fn sine_440_at_minus_6db(sample_rate: u32, secs: f32) -> Vec<i16> {
|
||
let n = (sample_rate as f32 * secs) as usize;
|
||
let amplitude: f32 = 0.5 * i16::MAX as f32; // -6 dBFS
|
||
(0..n)
|
||
.map(|i| {
|
||
let t = i as f32 / sample_rate as f32;
|
||
let v = (2.0 * std::f32::consts::PI * 440.0 * t).sin() * amplitude;
|
||
v as i16
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
#[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)
|
||
.expect("WAV decode must succeed");
|
||
// 1 second × 44.1 kHz mono = 44 100 frames; allow ±1 packet tolerance.
|
||
assert!(
|
||
(43_900..=44_300).contains(&frames),
|
||
"expected ~44100 frames, got {frames}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn count_mono_frames_returns_none_for_garbage_bytes() {
|
||
assert!(count_mono_frames_from_audio_bytes(b"not an audio file").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn count_mono_frames_returns_none_for_empty_bytes() {
|
||
assert!(count_mono_frames_from_audio_bytes(&[]).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)
|
||
.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");
|
||
assert_eq!(target_lufs, -14.0);
|
||
// -6 dBFS sine ≈ -9 LUFS integrated for 1.5 s. EBU R128 needs >=400 ms
|
||
// of audio; we have 1.5 s so the measurement is valid.
|
||
assert!(
|
||
(-30.0..0.0).contains(&integrated_lufs),
|
||
"integrated LUFS must be in a sane range, got {integrated_lufs}"
|
||
);
|
||
// True peak for -6 dBFS sine ≈ 0.5 linear amplitude.
|
||
assert!(
|
||
(0.4..=0.6).contains(&true_peak),
|
||
"true peak must reflect -6 dBFS amplitude, got {true_peak}"
|
||
);
|
||
// Recommended gain pushes the track toward the target LUFS,
|
||
// capped per `recommended_gain_for_target`.
|
||
assert!(recommended_gain_db.is_finite());
|
||
assert!((-24.0..=24.0).contains(&recommended_gain_db));
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
|
||
#[test]
|
||
fn analyze_loudness_returns_none_for_empty_bytes() {
|
||
assert!(analyze_loudness_and_waveform(&[], -14.0, 100).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();
|
||
assert_eq!(outcome, SeedFromBytesOutcome::Upserted);
|
||
assert_eq!(md5, md5_first_16kb(&wav), "outcome carries the content fingerprint");
|
||
|
||
// Both a waveform AND a loudness row must exist after a successful
|
||
// PCM decode + EBU R128 analysis.
|
||
let key = TrackKey {
|
||
server_id: "server-a".to_string(),
|
||
track_id: "wav-track".to_string(),
|
||
md5_16kb: md5_first_16kb(&wav),
|
||
};
|
||
let waveform = cache.get_waveform(&key).unwrap().expect("waveform cached");
|
||
assert_eq!(waveform.bin_count, 500);
|
||
assert_eq!(waveform.bins.len(), 1000, "bins are 2 * bin_count");
|
||
assert!(cache.loudness_row_exists_for_key(&key).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
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();
|
||
|
||
let md5 = md5_first_16kb(&wav);
|
||
let scoped = TrackKey {
|
||
server_id: "server-x".to_string(),
|
||
track_id: "scoped-track".to_string(),
|
||
md5_16kb: md5.clone(),
|
||
};
|
||
assert!(cache.get_waveform(&scoped).unwrap().is_some(), "row lands under server scope");
|
||
let other = TrackKey {
|
||
server_id: "server-y".to_string(),
|
||
track_id: "scoped-track".to_string(),
|
||
md5_16kb: md5,
|
||
};
|
||
assert!(cache.get_waveform(&other).unwrap().is_none(), "row stays under the exact server");
|
||
}
|
||
|
||
#[test]
|
||
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();
|
||
assert_eq!(first, SeedFromBytesOutcome::Upserted);
|
||
let (second, _) = seed_from_bytes_into_cache(&cache, "server-a", "wav-track-2", &wav).unwrap();
|
||
assert_eq!(
|
||
second,
|
||
SeedFromBytesOutcome::SkippedWaveformCacheHit,
|
||
"second seed sees cache + loudness rows and short-circuits"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn seed_from_bytes_into_cache_falls_back_to_byte_envelope_for_undecodable_input() {
|
||
let cache = AnalysisCache::open_in_memory();
|
||
// 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();
|
||
assert_eq!(outcome, SeedFromBytesOutcome::Upserted);
|
||
|
||
let key = TrackKey {
|
||
server_id: "server-a".to_string(),
|
||
track_id: "garbage".to_string(),
|
||
md5_16kb: md5_first_16kb(&bytes),
|
||
};
|
||
let waveform = cache.get_waveform(&key).unwrap().expect("byte-envelope waveform cached");
|
||
assert_eq!(waveform.bin_count, 500);
|
||
assert!(
|
||
!cache.loudness_row_exists_for_key(&key).unwrap(),
|
||
"byte-envelope fallback must not cache loudness"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn audio_duration_from_bytes_reports_duration_for_wav() {
|
||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 2.0), 44_100);
|
||
let duration = audio_duration_from_bytes(&wav).expect("duration must be available");
|
||
assert!(
|
||
(1.8..=2.2).contains(&duration),
|
||
"expected ~2s duration, got {duration}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn audio_duration_from_bytes_returns_none_for_garbage() {
|
||
assert!(audio_duration_from_bytes(b"not audio").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn decode_mono_pcm_limited_decodes_and_respects_limit() {
|
||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(48_000, 2.0), 48_000);
|
||
let (full_pcm, sr_full) = decode_mono_pcm_limited(&wav, None).expect("full decode");
|
||
assert_eq!(sr_full, 48_000.0);
|
||
assert!(
|
||
full_pcm.len() >= 95_000,
|
||
"2 seconds at 48kHz should decode close to 96k samples"
|
||
);
|
||
|
||
let (limited_pcm, sr_limited) =
|
||
decode_mono_pcm_limited(&wav, Some(0.25)).expect("limited decode");
|
||
assert_eq!(sr_limited, 48_000.0);
|
||
assert!(
|
||
(11_500..=12_500).contains(&limited_pcm.len()),
|
||
"0.25 seconds at 48kHz should decode ~12k samples, got {}",
|
||
limited_pcm.len()
|
||
);
|
||
assert!(limited_pcm.len() < full_pcm.len());
|
||
}
|
||
|
||
#[test]
|
||
fn decode_mono_pcm_limited_rejects_empty_buffer() {
|
||
let err = decode_mono_pcm_limited(&[], Some(1.0)).unwrap_err();
|
||
assert!(err.contains("empty audio buffer"));
|
||
}
|
||
|
||
#[test]
|
||
fn decode_mono_pcm_window_decodes_center_slice() {
|
||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 2.0), 44_100);
|
||
let (window_pcm, sr) = decode_mono_pcm_window(&wav, 0.75, 0.5).expect("window decode");
|
||
assert_eq!(sr, 44_100.0);
|
||
assert!(
|
||
(20_000..=24_000).contains(&window_pcm.len()),
|
||
"0.5 seconds at 44.1kHz should decode ~22k samples, got {}",
|
||
window_pcm.len()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn decode_mono_pcm_window_rejects_invalid_bytes() {
|
||
let err = decode_mono_pcm_window(b"not-audio", 0.0, 1.0).unwrap_err();
|
||
assert!(
|
||
err.contains("failed to open audio decode session"),
|
||
"unexpected error: {err}"
|
||
);
|
||
}
|
||
|
||
#[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");
|
||
assert_eq!(scanned.bins.len(), 128);
|
||
assert!(scanned.loudness.is_none());
|
||
}
|
||
|
||
#[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");
|
||
assert_eq!(scanned.bins.len(), 128);
|
||
let (integrated_lufs, true_peak, recommended_gain_db, target_lufs) =
|
||
scanned.loudness.expect("loudness tuple must be present");
|
||
assert!(integrated_lufs.is_finite());
|
||
assert!(true_peak.is_finite());
|
||
assert!((-24.0..=24.0).contains(&recommended_gain_db));
|
||
assert_eq!(target_lufs, -14.0);
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
|
||
#[test]
|
||
fn seed_from_bytes_reanalyzes_when_waveform_exists_without_loudness() {
|
||
let cache = AnalysisCache::open_in_memory();
|
||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
|
||
let md5 = md5_first_16kb(&wav);
|
||
let key = TrackKey {
|
||
server_id: "server-a".to_string(),
|
||
track_id: "track-reseed".to_string(),
|
||
md5_16kb: md5,
|
||
};
|
||
cache.touch_track_status(&key, "ready").unwrap();
|
||
cache
|
||
.upsert_waveform(
|
||
&key,
|
||
&WaveformEntry {
|
||
bins: vec![8u8; 1000],
|
||
bin_count: 500,
|
||
is_partial: false,
|
||
known_until_sec: 0.0,
|
||
duration_sec: 0.0,
|
||
updated_at: now_unix_ts(),
|
||
},
|
||
)
|
||
.unwrap();
|
||
assert!(!cache.loudness_row_exists_for_key(&key).unwrap());
|
||
|
||
let (outcome, _) =
|
||
seed_from_bytes_into_cache(&cache, "server-a", "track-reseed", &wav).unwrap();
|
||
assert_eq!(outcome, SeedFromBytesOutcome::Upserted);
|
||
assert!(cache.loudness_row_exists_for_key(&key).unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn analysis_pcm_window_handles_negative_and_non_finite_durations() {
|
||
let neg = analysis_pcm_window(-42.0, 60.0);
|
||
assert_eq!(neg.start_sec, 0.0);
|
||
assert_eq!(neg.duration_sec, 60.0);
|
||
|
||
let inf = analysis_pcm_window(f64::INFINITY, 60.0);
|
||
assert_eq!(inf.start_sec, 0.0);
|
||
assert!(!inf.duration_sec.is_finite());
|
||
}
|
||
|
||
#[test]
|
||
fn decode_mono_pcm_window_rejects_empty_buffer() {
|
||
let err = decode_mono_pcm_window(&[], 0.0, 1.0).unwrap_err();
|
||
assert!(err.contains("empty audio buffer"));
|
||
}
|
||
|
||
#[test]
|
||
fn decode_mono_pcm_limited_rejects_invalid_bytes() {
|
||
let err = decode_mono_pcm_limited(b"not-audio", Some(0.5)).unwrap_err();
|
||
assert!(err.contains("failed to open audio decode session"));
|
||
}
|
||
|
||
#[test]
|
||
fn decode_mono_pcm_limited_ignores_non_positive_or_non_finite_cap() {
|
||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 1.0), 44_100);
|
||
let (full_a, _) = decode_mono_pcm_limited(&wav, None).unwrap();
|
||
let (full_b, _) = decode_mono_pcm_limited(&wav, Some(0.0)).unwrap();
|
||
let (full_c, _) = decode_mono_pcm_limited(&wav, Some(f64::NAN)).unwrap();
|
||
assert_eq!(full_a.len(), full_b.len());
|
||
assert_eq!(full_a.len(), full_c.len());
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
|
||
#[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();
|
||
assert_eq!(scanned.bins.len(), 128);
|
||
}
|
||
|
||
#[test]
|
||
fn seed_from_bytes_execute_returns_no_cache_without_registered_state() {
|
||
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)
|
||
.expect("seed execute should return a graceful skip");
|
||
assert_eq!(outcome, SeedFromBytesOutcome::SkippedNoAnalysisCache);
|
||
assert_eq!(timings.seed_ms, 0);
|
||
assert_eq!(timings.bpm_ms, 0);
|
||
}
|
||
|
||
#[test]
|
||
fn seed_from_bytes_execute_runs_with_registered_cache() {
|
||
let app = tauri::test::mock_app();
|
||
app.manage(AnalysisCache::open_in_memory());
|
||
let wav = build_mono_pcm16_wav(&sine_440_at_minus_6db(44_100, 0.5), 44_100);
|
||
let handle = app.handle().clone();
|
||
|
||
let (first, timings_first) =
|
||
seed_from_bytes_execute(&handle, "server-a", "track-exec", &wav).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).unwrap();
|
||
assert_eq!(second, SeedFromBytesOutcome::SkippedWaveformCacheHit);
|
||
assert!(timings_second.seed_ms <= 30_000);
|
||
}
|
||
}
|