forked from Keelrleek/SoulStream
playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "audio-formats"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -0,0 +1,166 @@
|
||||
//! What counts as audio, and what the player can actually decode.
|
||||
//!
|
||||
//! These were seven separate hand-kept lists — the library scanner, the share
|
||||
//! index, the row pruner, the loudness pass, and three in the frontend — and
|
||||
//! they disagreed. The scanner indexed `.aiff`, the pruner did not recognise it
|
||||
//! and DELETED the rows on the next startup. The share index knew `.ape`, the
|
||||
//! scanner did not. Every list named `.opus`, which nothing here can decode.
|
||||
//!
|
||||
//! One table now, with a distinction the old lists could not express:
|
||||
//!
|
||||
//! - [`PLAYABLE`] — the decoder can turn this into sound. Derived from the
|
||||
//! codecs symphonia is actually built with (`all-codecs` + `all-formats`:
|
||||
//! aac, adpcm, alac, flac, mp1/2/3, pcm, vorbis; caf, isomp4, mkv, ogg,
|
||||
//! aiff, wav) and verified against real files by
|
||||
//! `cargo run -p player --example probe_formats`.
|
||||
//! - [`RECOGNISED`] — audio we can index, share, download and show, but cannot
|
||||
//! play. Naming these is the point: a file the app can see and refuses to
|
||||
//! play with a clear reason is honest, whereas leaving it out of the tables
|
||||
//! makes it invisible, and claiming it plays makes the player fail at the
|
||||
//! moment the user presses play.
|
||||
//!
|
||||
//! Adding a codec means adding a decoder, not editing a list.
|
||||
|
||||
/// Extensions the player can decode. Verified, not assumed.
|
||||
pub const PLAYABLE: &[&str] = &[
|
||||
// FLAC
|
||||
"flac",
|
||||
// MPEG audio — the bundle provides layers I, II and III
|
||||
"mp3", "mp2", "mp1",
|
||||
// ISO/MP4 container: AAC and ALAC live here
|
||||
"m4a", "m4b", "mp4", "m4r",
|
||||
// Raw AAC (ADTS)
|
||||
"aac",
|
||||
// Ogg container, Vorbis inside
|
||||
"ogg", "oga",
|
||||
// RIFF: PCM and ADPCM
|
||||
"wav", "wave",
|
||||
// AIFF / AIFF-C, also via the RIFF reader
|
||||
"aiff", "aif", "aifc",
|
||||
// Core Audio Format
|
||||
"caf",
|
||||
// Matroska — FLAC/Vorbis/AAC in a different wrapper
|
||||
"mka", "webm",
|
||||
];
|
||||
|
||||
/// Audio we recognise but cannot decode. Indexed and shared; never played.
|
||||
///
|
||||
/// `wv` earns its place here more than any of them. A WavPack file that
|
||||
/// carries the original RIFF header in its wrapper is probed as raw PCM, so
|
||||
/// the decoder does not fail — it reads compressed bytes as samples and emits
|
||||
/// NaN into the mixer. Refusing it by extension is the only thing that stops
|
||||
/// that, because the decoder itself reports success.
|
||||
pub const RECOGNISED: &[&str] = &[
|
||||
"opus", // no Opus decoder is built in
|
||||
"wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
|
||||
];
|
||||
|
||||
/// Is this an audio file at all — playable or merely recognised?
|
||||
///
|
||||
/// The scanner, the share index and the row pruner must all answer this the
|
||||
/// same way, or a file is indexed by one and deleted by another.
|
||||
pub fn is_audio_ext(ext: &str) -> bool {
|
||||
let e = ext.to_ascii_lowercase();
|
||||
PLAYABLE.contains(&e.as_str()) || RECOGNISED.contains(&e.as_str())
|
||||
}
|
||||
|
||||
/// Can the player turn this into sound?
|
||||
pub fn is_playable_ext(ext: &str) -> bool {
|
||||
PLAYABLE.contains(&ext.to_ascii_lowercase().as_str())
|
||||
}
|
||||
|
||||
/// Why this file will not play, in words that can go on screen.
|
||||
pub fn unplayable_reason(ext: &str) -> Option<&'static str> {
|
||||
match ext.to_ascii_lowercase().as_str() {
|
||||
"opus" => Some("Opus isn't supported yet — this build has no Opus decoder"),
|
||||
"wv" => Some("WavPack isn't supported yet"),
|
||||
"ape" => Some("Monkey's Audio (APE) isn't supported yet"),
|
||||
"wma" => Some("Windows Media Audio isn't supported yet"),
|
||||
"mpc" => Some("Musepack isn't supported yet"),
|
||||
"tta" => Some("True Audio isn't supported yet"),
|
||||
"shn" => Some("Shorten isn't supported yet"),
|
||||
"dsf" | "dff" => Some("DSD isn't supported yet"),
|
||||
e if RECOGNISED.contains(&e) => Some("this format isn't supported yet"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ext_of(path: &std::path::Path) -> Option<String> {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
pub fn is_audio_path(path: &std::path::Path) -> bool {
|
||||
ext_of(path).is_some_and(|e| is_audio_ext(&e))
|
||||
}
|
||||
|
||||
pub fn is_playable_path(path: &std::path::Path) -> bool {
|
||||
ext_of(path).is_some_and(|e| is_playable_ext(&e))
|
||||
}
|
||||
|
||||
/// Why this path will not play. `None` means "go ahead and try".
|
||||
pub fn unplayable_path_reason(path: &std::path::Path) -> Option<&'static str> {
|
||||
unplayable_reason(&ext_of(path)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
/// The bug the single table exists to prevent: the scanner indexed a
|
||||
/// format the pruner did not recognise, so importing an AIFF album put it
|
||||
/// in the library and the next startup silently deleted it.
|
||||
#[test]
|
||||
fn everything_indexed_survives_the_pruner() {
|
||||
for ext in PLAYABLE.iter().chain(RECOGNISED.iter()) {
|
||||
assert!(is_audio_ext(ext), "{ext} indexed but not recognised as audio");
|
||||
}
|
||||
// The extensions that were actually in the divergence.
|
||||
for ext in ["aiff", "aif", "ape", "wv", "opus"] {
|
||||
assert!(is_audio_ext(ext), "{ext} must be recognised");
|
||||
}
|
||||
}
|
||||
|
||||
/// A format cannot be both — every entry has one answer.
|
||||
#[test]
|
||||
fn no_extension_is_in_both_tables() {
|
||||
for e in PLAYABLE {
|
||||
assert!(!RECOGNISED.contains(e), "{e} is in both tables");
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognised-but-not-playable must carry a reason, or the UI has nothing
|
||||
/// to say when it refuses.
|
||||
#[test]
|
||||
fn every_unplayable_format_can_explain_itself() {
|
||||
for e in RECOGNISED {
|
||||
assert!(unplayable_reason(e).is_some(), "{e} has no reason");
|
||||
}
|
||||
for e in PLAYABLE {
|
||||
assert!(unplayable_reason(e).is_none(), "{e} plays but claims it cannot");
|
||||
}
|
||||
}
|
||||
|
||||
/// WavPack is the dangerous one: the decoder does NOT fail on it, so only
|
||||
/// an extension check keeps NaN out of the mixer.
|
||||
#[test]
|
||||
fn wavpack_is_refused_before_the_decoder_sees_it() {
|
||||
assert!(!is_playable_ext("wv"));
|
||||
assert!(is_audio_ext("wv"));
|
||||
assert_eq!(
|
||||
unplayable_path_reason(Path::new("C:\\m\\a.WV")),
|
||||
Some("WavPack isn't supported yet"),
|
||||
"case must not decide whether audio plays"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_and_paths() {
|
||||
assert!(is_playable_ext("FLAC"));
|
||||
assert!(is_playable_path(Path::new("C:\\m\\01 - Song.Mp3")));
|
||||
assert!(!is_audio_path(Path::new("C:\\m\\cover.jpg")));
|
||||
assert!(!is_audio_path(Path::new("C:\\m\\noext")));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
audio-formats = { path = "../audio-formats" }
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
+1357
-80
File diff suppressed because it is too large.
Load diff
+856
-98
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@ edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
audio-formats = { path = "../audio-formats" }
|
||||
rodio = { version = "0.21", features = ["symphonia-all"] }
|
||||
symphonia = { version = "0.5", features = ["all"] }
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Ask the REAL decoder what it can open.
|
||||
//!
|
||||
//! The app carries several hand-maintained lists of "audio extensions", and a
|
||||
//! list is a claim, not a capability — the only thing that settles whether a
|
||||
//! file plays is building the same decoder the player builds.
|
||||
//!
|
||||
//! cargo run -p player --example probe_formats -- <file-or-folder> [more...]
|
||||
//!
|
||||
//! A folder is walked and one file per extension is probed, which is what makes
|
||||
//! this cheap to point at a whole library.
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.is_empty() {
|
||||
eprintln!("usage: probe_formats <file-or-folder>...");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
// One representative per extension: the question is "can this FORMAT be
|
||||
// decoded", and a thousand MP3s answer it no better than one.
|
||||
let mut sample: BTreeMap<String, PathBuf> = BTreeMap::new();
|
||||
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for a in &args {
|
||||
collect(Path::new(a), &mut sample, &mut counts, 0);
|
||||
}
|
||||
|
||||
println!("{:<8} {:>7} {:<9} {}", "EXT", "FILES", "VERDICT", "DETAIL");
|
||||
for (ext, path) in &sample {
|
||||
let (verdict, detail) = probe(path);
|
||||
println!(
|
||||
"{:<8} {:>7} {:<9} {}",
|
||||
ext,
|
||||
counts.get(ext).copied().unwrap_or(0),
|
||||
verdict,
|
||||
detail
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect(
|
||||
p: &Path,
|
||||
sample: &mut BTreeMap<String, PathBuf>,
|
||||
counts: &mut BTreeMap<String, usize>,
|
||||
depth: u8,
|
||||
) {
|
||||
if depth > 8 {
|
||||
return;
|
||||
}
|
||||
if p.is_dir() {
|
||||
let Ok(entries) = std::fs::read_dir(p) else { return };
|
||||
for e in entries.flatten() {
|
||||
collect(&e.path(), sample, counts, depth + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(ext) = p.extension().and_then(|e| e.to_str()) else { return };
|
||||
let ext = ext.to_ascii_lowercase();
|
||||
// Anything that is plausibly audio, INCLUDING formats no list mentions —
|
||||
// the point is to discover the gap, not to confirm the lists.
|
||||
const PLAUSIBLE: &[&str] = &[
|
||||
"flac", "mp3", "mp2", "mp1", "m4a", "m4b", "mp4", "aac", "alac", "ogg", "oga", "opus",
|
||||
"wav", "wave", "aiff", "aif", "aifc", "caf", "mka", "webm", "wma", "ape", "wv", "mpc",
|
||||
"tta", "shn", "dsf", "dff", "ra", "au", "amr", "3gp", "spx",
|
||||
];
|
||||
if !PLAUSIBLE.contains(&ext.as_str()) {
|
||||
return;
|
||||
}
|
||||
*counts.entry(ext.clone()).or_insert(0) += 1;
|
||||
sample.entry(ext).or_insert_with(|| p.to_path_buf());
|
||||
}
|
||||
|
||||
/// Build exactly what `player` builds for a local file.
|
||||
fn probe(path: &Path) -> (&'static str, String) {
|
||||
use rodio::Decoder;
|
||||
let file = match std::fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => return ("ERROR", format!("open failed: {e}")),
|
||||
};
|
||||
let len = file.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let reader = std::io::BufReader::new(file);
|
||||
let mut builder = Decoder::builder().with_data(reader).with_seekable(true);
|
||||
if len > 0 {
|
||||
builder = builder.with_byte_len(len);
|
||||
}
|
||||
// What did symphonia actually RECOGNISE? A verdict of "plays" is worth
|
||||
// little if the container was mistaken for something else, so name the
|
||||
// format and codec rather than inferring capability from the extension.
|
||||
let detected = detect(path).unwrap_or_else(|| "?".into());
|
||||
match builder.build() {
|
||||
Ok(d) => {
|
||||
// Opening is not playing. A container can parse while its codec is
|
||||
// missing, and a truncated decode looks like success if you only
|
||||
// pull a few thousand samples — so decode the WHOLE thing and check
|
||||
// the duration against the file. A 30 MB lossless track that yields
|
||||
// two seconds of audio has not been decoded, it has been guessed at.
|
||||
use rodio::Source as _;
|
||||
let rate = d.sample_rate();
|
||||
let ch = d.channels();
|
||||
let mut src = d;
|
||||
// Amplitude tells apart "decoded" from "mis-identified". Compressed
|
||||
// bytes read as raw PCM are full-scale noise: near-100% of samples
|
||||
// land loud, where real music spends most of its time quiet.
|
||||
let (mut n, mut sumsq, mut hot, mut bad) = (0u64, 0f64, 0u64, 0u64);
|
||||
for s in src.by_ref() {
|
||||
let v = s as f64;
|
||||
// A non-finite sample is proof on its own: no decoder emits
|
||||
// NaN from real audio. It comes from compressed bytes being
|
||||
// reinterpreted as floats, and it poisons every mixer and
|
||||
// normaliser stage downstream of here.
|
||||
if !v.is_finite() {
|
||||
bad += 1;
|
||||
n += 1;
|
||||
continue;
|
||||
}
|
||||
sumsq += v * v;
|
||||
if v.abs() > 0.5 {
|
||||
hot += 1;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
let rms = if n > 0 { (sumsq / n as f64).sqrt() } else { 0.0 };
|
||||
let hot_pct = if n > 0 { hot as f64 * 100.0 / n as f64 } else { 0.0 };
|
||||
let frames = n as f64 / (ch.max(1) as f64);
|
||||
let secs = frames / rate.max(1) as f64;
|
||||
let mb = len as f64 / 1_048_576.0;
|
||||
if secs < 1.0 {
|
||||
return ("NO AUDIO", format!("{rate} Hz, {ch}ch — {secs:.2}s from {mb:.1} MB"));
|
||||
}
|
||||
// Even the densest lossless runs well under 10 MB per second.
|
||||
if mb / secs > 10.0 {
|
||||
return (
|
||||
"PARTIAL",
|
||||
format!("{rate} Hz, {ch}ch — only {secs:.1}s from {mb:.1} MB"),
|
||||
);
|
||||
}
|
||||
// Half the samples above half scale is not music. This is the
|
||||
// WavPack case: a RIFF header inside the wrapper makes symphonia
|
||||
// read the compressed body as PCM, so it "succeeds" into noise —
|
||||
// which is worse than refusing, because nothing reports an error.
|
||||
if bad > 0 {
|
||||
return (
|
||||
"GARBAGE",
|
||||
format!(
|
||||
"{detected} mis-identified — {} of {n} samples are NaN/Inf",
|
||||
bad
|
||||
),
|
||||
);
|
||||
}
|
||||
if hot_pct > 40.0 && rms > 0.35 {
|
||||
return (
|
||||
"NOISE",
|
||||
format!("{detected} mis-identified — rms {rms:.2}, {hot_pct:.0}% of samples above half scale"),
|
||||
);
|
||||
}
|
||||
(
|
||||
"PLAYS",
|
||||
format!("{detected} {rate} Hz, {ch}ch, {secs:.0}s, rms {rms:.2}"),
|
||||
)
|
||||
}
|
||||
Err(e) => ("CANNOT", format!("{detected} {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The codec symphonia identifies, straight from the probe — the ground truth
|
||||
/// behind a "plays" verdict.
|
||||
fn detect(path: &Path) -> Option<String> {
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::probe::Hint;
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(&hint, mss, &Default::default(), &Default::default())
|
||||
.ok()?;
|
||||
let track = probed.format.default_track()?;
|
||||
let codec = track.codec_params.codec;
|
||||
let name = symphonia::default::get_codecs()
|
||||
.get_codec(codec)
|
||||
.map(|d| d.short_name.to_string())
|
||||
.unwrap_or_else(|| format!("codec:{codec:?}"));
|
||||
Some(name)
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
mod loudness;
|
||||
mod source;
|
||||
pub mod vocals;
|
||||
mod waveform;
|
||||
|
||||
pub use loudness::{analyze_loudness, Analysis, AnalysisSource, Incomplete, LoudnessMeter};
|
||||
@@ -494,6 +495,19 @@ fn start_decoder(
|
||||
.into(),
|
||||
)
|
||||
.spawn(move || {
|
||||
// Refuse formats there is no decoder for, BEFORE opening them.
|
||||
//
|
||||
// Most simply fail to build and report themselves. WavPack does
|
||||
// not: a `.wv` carrying the original RIFF header in its wrapper is
|
||||
// probed as raw PCM, so the decoder reports success and streams the
|
||||
// compressed body as samples — measured at 136,815 NaN/Inf values
|
||||
// in a single track, straight into the mixer and the normaliser.
|
||||
// An extension check is the only thing that can stop that, because
|
||||
// by the time there is a decoder there is no error to notice.
|
||||
if let Some(reason) = audio_formats::unplayable_path_reason(&track.path) {
|
||||
let _ = tx.send(Err((generation, reason.to_string())));
|
||||
return;
|
||||
}
|
||||
let (reader, byte_len): (Box<dyn source::MediaRead>, u64) = match track.stream.clone() {
|
||||
Some(progress) => {
|
||||
match GrowingFile::open(&track.path, track.total_bytes, progress) {
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
//! Vocal-band DSP for the lyrics feature: a leading-silence probe (auto-sync
|
||||
//! evidence) and an offline vocal-reduction renderer (the classic band-limited
|
||||
//! center-cancel, NOT a learned separator — honest about what it is).
|
||||
//!
|
||||
//! Everything here is offline file work. Nothing touches the live audio
|
||||
//! engine: the renderer writes an ordinary WAV that plays through the normal
|
||||
//! local-file path, so a bug in this module can never destabilize playback.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::Path;
|
||||
|
||||
use symphonia::core::audio::SampleBuffer;
|
||||
use symphonia::core::codecs::DecoderOptions;
|
||||
use symphonia::core::formats::FormatOptions;
|
||||
use symphonia::core::io::MediaSourceStream;
|
||||
use symphonia::core::meta::MetadataOptions;
|
||||
use symphonia::core::probe::Hint;
|
||||
|
||||
/// Longest source the renderer accepts. A WAV render holds the whole track
|
||||
/// in memory (16-bit stereo ≈ 10.6 MB/min at 44.1k); twenty minutes bounds
|
||||
/// that at ~212 MB and excludes accidental full-set files.
|
||||
const MAX_RENDER_SECS: f64 = 20.0 * 60.0;
|
||||
|
||||
/// RMS floor treated as "audible": −50 dBFS, sustained. Modern releases sit
|
||||
/// far above this; dithered lead-in noise sits below it.
|
||||
const SILENCE_RMS: f32 = 0.003_16;
|
||||
/// Frame size for the silence scan (about 20 ms at 44.1 kHz).
|
||||
const SILENCE_FRAME: usize = 1024;
|
||||
/// How many consecutive audible frames end the silence (~60 ms) — a single
|
||||
/// vinyl tick or DC blip must not count as the music starting.
|
||||
const SILENCE_RUN: usize = 3;
|
||||
/// The silence probe never reads past this point; a track that has not
|
||||
/// started within thirty seconds is not a case auto-sync should judge.
|
||||
const SILENCE_SCAN_SECS: f64 = 30.0;
|
||||
|
||||
// ---- decoding --------------------------------------------------------------
|
||||
|
||||
struct Decoded {
|
||||
/// Interleaved f32 frames.
|
||||
samples: Vec<f32>,
|
||||
channels: usize,
|
||||
sample_rate: u32,
|
||||
}
|
||||
|
||||
/// Decode `path` fully (or up to `limit_secs`) into interleaved f32.
|
||||
fn decode(path: &Path, limit_secs: Option<f64>) -> Result<Decoded, String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
let mut hint = Hint::new();
|
||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
hint.with_extension(ext);
|
||||
}
|
||||
let probed = symphonia::default::get_probe()
|
||||
.format(
|
||||
&hint,
|
||||
mss,
|
||||
&FormatOptions::default(),
|
||||
&MetadataOptions::default(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut format = probed.format;
|
||||
let track = format
|
||||
.default_track()
|
||||
.ok_or("no default audio track")?
|
||||
.clone();
|
||||
let mut decoder = symphonia::default::get_codecs()
|
||||
.make(&track.codec_params, &DecoderOptions::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut samples = Vec::new();
|
||||
let mut channels = 0usize;
|
||||
let mut sample_rate = 0u32;
|
||||
let mut buffer: Option<SampleBuffer<f32>> = None;
|
||||
loop {
|
||||
let packet = match format.next_packet() {
|
||||
Ok(p) => p,
|
||||
Err(symphonia::core::errors::Error::IoError(_)) => break,
|
||||
Err(symphonia::core::errors::Error::ResetRequired) => break,
|
||||
Err(_) => break,
|
||||
};
|
||||
if packet.track_id() != track.id {
|
||||
continue;
|
||||
}
|
||||
let decoded = match decoder.decode(&packet) {
|
||||
Ok(d) => d,
|
||||
Err(symphonia::core::errors::Error::DecodeError(_)) => continue,
|
||||
Err(_) => break,
|
||||
};
|
||||
let spec = *decoded.spec();
|
||||
channels = spec.channels.count();
|
||||
sample_rate = spec.rate;
|
||||
let out = buffer.get_or_insert_with(|| {
|
||||
SampleBuffer::<f32>::new(decoded.capacity() as u64, spec)
|
||||
});
|
||||
if out.capacity() < decoded.capacity() * channels {
|
||||
*out = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
|
||||
}
|
||||
out.copy_interleaved_ref(decoded);
|
||||
samples.extend_from_slice(out.samples());
|
||||
if let Some(limit) = limit_secs {
|
||||
if sample_rate > 0
|
||||
&& (samples.len() / channels.max(1)) as f64 / sample_rate as f64 >= limit
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if samples.is_empty() || channels == 0 || sample_rate == 0 {
|
||||
return Err("nothing decodable".into());
|
||||
}
|
||||
Ok(Decoded {
|
||||
samples,
|
||||
channels,
|
||||
sample_rate,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- leading silence -------------------------------------------------------
|
||||
|
||||
/// Milliseconds of leading silence in `path`, or None when the file cannot be
|
||||
/// decoded / never becomes audible inside the scan window.
|
||||
pub fn leading_silence_ms(path: &Path) -> Option<u64> {
|
||||
let decoded = decode(path, Some(SILENCE_SCAN_SECS)).ok()?;
|
||||
let frames_rms = fold_rms(&decoded.samples, decoded.channels, SILENCE_FRAME);
|
||||
let onset_frame = onset_from_rms(&frames_rms)?;
|
||||
Some(
|
||||
(onset_frame as u64 * SILENCE_FRAME as u64 * 1000)
|
||||
/ decoded.sample_rate.max(1) as u64,
|
||||
)
|
||||
}
|
||||
|
||||
/// Interleaved samples → per-frame mono RMS.
|
||||
fn fold_rms(samples: &[f32], channels: usize, frame: usize) -> Vec<f32> {
|
||||
let channels = channels.max(1);
|
||||
let per_frame = frame * channels;
|
||||
samples
|
||||
.chunks(per_frame)
|
||||
.map(|chunk| {
|
||||
let sum: f32 = chunk.iter().map(|s| s * s).sum();
|
||||
(sum / chunk.len().max(1) as f32).sqrt()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// First frame index beginning [`SILENCE_RUN`] consecutive audible frames.
|
||||
fn onset_from_rms(rms: &[f32]) -> Option<usize> {
|
||||
let mut run = 0usize;
|
||||
for (index, &value) in rms.iter().enumerate() {
|
||||
if value > SILENCE_RMS {
|
||||
run += 1;
|
||||
if run >= SILENCE_RUN {
|
||||
return Some(index + 1 - run);
|
||||
}
|
||||
} else {
|
||||
run = 0;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---- vocal activity --------------------------------------------------------
|
||||
|
||||
/// A coarse "is someone likely singing here" envelope: the energy of the
|
||||
/// CENTER (mid) channel band-passed to the vocal range, one value per
|
||||
/// `frame_ms`, normalized 0..1 against its own loud end. The same physics
|
||||
/// the vocal reducer exploits — center-mixed vocals dominate the mid band —
|
||||
/// read instead of removed. A proxy, not a detector: instruments living
|
||||
/// center-mid (lead guitar, synth hooks) light it up too, which is why the
|
||||
/// rough-sync generator that consumes it is labeled ESTIMATED and hands the
|
||||
/// result to the correction editor rather than presenting it as truth.
|
||||
pub struct VocalActivity {
|
||||
pub frame_ms: u32,
|
||||
/// 0..1 per frame.
|
||||
pub frames: Vec<f32>,
|
||||
}
|
||||
|
||||
pub fn vocal_activity(path: &Path, frame_ms: u32) -> Result<VocalActivity, String> {
|
||||
let decoded = decode(path, Some(MAX_RENDER_SECS + 2.0))?;
|
||||
let frame_ms = frame_ms.clamp(40, 500);
|
||||
let frame_len = (decoded.sample_rate as usize * frame_ms as usize) / 1000;
|
||||
let channels = decoded.channels;
|
||||
let mut low = Biquad::highpass(decoded.sample_rate as f32, 180.0);
|
||||
let mut high = Biquad::lowpass(decoded.sample_rate as f32, 7_500.0);
|
||||
let mut frames = Vec::new();
|
||||
let mut acc = 0.0f64;
|
||||
let mut n = 0usize;
|
||||
for frame in decoded.samples.chunks(channels) {
|
||||
let mid = if channels >= 2 {
|
||||
0.5 * (frame[0] + frame[1])
|
||||
} else {
|
||||
frame[0]
|
||||
};
|
||||
let banded = high.process(low.process(mid));
|
||||
acc += (banded * banded) as f64;
|
||||
n += 1;
|
||||
if n >= frame_len {
|
||||
frames.push((acc / n as f64).sqrt() as f32);
|
||||
acc = 0.0;
|
||||
n = 0;
|
||||
}
|
||||
}
|
||||
if n > frame_len / 4 {
|
||||
frames.push((acc / n.max(1) as f64).sqrt() as f32);
|
||||
}
|
||||
if frames.is_empty() {
|
||||
return Err("nothing decodable".into());
|
||||
}
|
||||
// Normalize against the loud end (95th percentile), so one hot chorus
|
||||
// does not flatten the whole song to zero.
|
||||
let mut sorted: Vec<f32> = frames.clone();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let p95 = sorted[(sorted.len() * 95 / 100).min(sorted.len() - 1)].max(1e-6);
|
||||
for value in &mut frames {
|
||||
*value = (*value / p95).min(1.0);
|
||||
}
|
||||
Ok(VocalActivity { frame_ms, frames })
|
||||
}
|
||||
|
||||
// ---- vocal reduction -------------------------------------------------------
|
||||
|
||||
/// What the renderer produced (for the caller's labels and sanity checks).
|
||||
pub struct VocalReduction {
|
||||
pub duration_secs: f64,
|
||||
pub sample_rate: u32,
|
||||
}
|
||||
|
||||
/// Render a vocal-reduced copy of `src` to `dest_wav` (16-bit stereo WAV).
|
||||
///
|
||||
/// The method is the classic out-of-phase cancellation with band limiting:
|
||||
/// the center (mid) channel is removed in the vocal band while the lows
|
||||
/// (bass, kick) and the highest air band are kept, and the side content
|
||||
/// passes through untouched. It works in proportion to how center-mixed the
|
||||
/// lead vocal is — modern wide/reverbed vocals survive partially. That limit
|
||||
/// is inherent to the technique; the honest fix is the future learned
|
||||
/// separator the research bundle scopes.
|
||||
pub fn render_vocal_reduced(src: &Path, dest_wav: &Path) -> Result<VocalReduction, String> {
|
||||
// The decode itself stops just past the cap, so an accidental multi-hour
|
||||
// file cannot balloon memory before the refusal fires.
|
||||
let decoded = decode(src, Some(MAX_RENDER_SECS + 2.0))?;
|
||||
if decoded.channels < 2 {
|
||||
return Err("This recording is mono — there is no center channel to cancel".into());
|
||||
}
|
||||
let frames = decoded.samples.len() / decoded.channels;
|
||||
let duration = frames as f64 / decoded.sample_rate as f64;
|
||||
if duration > MAX_RENDER_SECS {
|
||||
return Err("Track is too long for the instrumental renderer (20 min cap)".into());
|
||||
}
|
||||
// First two channels carry the stereo image; extra channels are dropped.
|
||||
let mut stereo: Vec<f32> = Vec::with_capacity(frames * 2);
|
||||
for frame in decoded.samples.chunks(decoded.channels) {
|
||||
stereo.push(frame[0]);
|
||||
stereo.push(frame[1]);
|
||||
}
|
||||
process_center_cancel(&mut stereo, decoded.sample_rate);
|
||||
write_wav_16(dest_wav, &stereo, decoded.sample_rate)?;
|
||||
Ok(VocalReduction {
|
||||
duration_secs: duration,
|
||||
sample_rate: decoded.sample_rate,
|
||||
})
|
||||
}
|
||||
|
||||
/// In-place band-limited center cancellation over interleaved stereo.
|
||||
///
|
||||
/// out_L = side + LP(mid) + HP(mid); out_R = −side + LP(mid) + HP(mid) —
|
||||
/// removing the mid band where vocals live (~180 Hz–7.5 kHz) and keeping the
|
||||
/// side image, the lows, and the air.
|
||||
fn process_center_cancel(stereo: &mut [f32], sample_rate: u32) {
|
||||
let mut low = Biquad::lowpass(sample_rate as f32, 180.0);
|
||||
let mut high = Biquad::highpass(sample_rate as f32, 7_500.0);
|
||||
let keep_air = sample_rate >= 22_050;
|
||||
for frame in stereo.chunks_mut(2) {
|
||||
let (l, r) = (frame[0], frame[1]);
|
||||
let mid = 0.5 * (l + r);
|
||||
let side = 0.5 * (l - r);
|
||||
let lows = low.process(mid);
|
||||
let air = if keep_air { high.process(mid) } else { 0.0 };
|
||||
let kept = lows + air;
|
||||
frame[0] = ((side + kept) * 0.95).clamp(-1.0, 1.0);
|
||||
frame[1] = ((kept - side) * 0.95).clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// RBJ-cookbook biquad, Butterworth Q.
|
||||
struct Biquad {
|
||||
b0: f32,
|
||||
b1: f32,
|
||||
b2: f32,
|
||||
a1: f32,
|
||||
a2: f32,
|
||||
x1: f32,
|
||||
x2: f32,
|
||||
y1: f32,
|
||||
y2: f32,
|
||||
}
|
||||
|
||||
impl Biquad {
|
||||
fn new(b0: f32, b1: f32, b2: f32, a0: f32, a1: f32, a2: f32) -> Biquad {
|
||||
Biquad {
|
||||
b0: b0 / a0,
|
||||
b1: b1 / a0,
|
||||
b2: b2 / a0,
|
||||
a1: a1 / a0,
|
||||
a2: a2 / a0,
|
||||
x1: 0.0,
|
||||
x2: 0.0,
|
||||
y1: 0.0,
|
||||
y2: 0.0,
|
||||
}
|
||||
}
|
||||
fn lowpass(sample_rate: f32, fc: f32) -> Biquad {
|
||||
let w = 2.0 * std::f32::consts::PI * fc / sample_rate;
|
||||
// Butterworth: alpha = sin(w)/(2·Q) with Q = 1/√2.
|
||||
let alpha = w.sin() * std::f32::consts::FRAC_1_SQRT_2;
|
||||
let c = w.cos();
|
||||
Biquad::new(
|
||||
(1.0 - c) / 2.0,
|
||||
1.0 - c,
|
||||
(1.0 - c) / 2.0,
|
||||
1.0 + alpha,
|
||||
-2.0 * c,
|
||||
1.0 - alpha,
|
||||
)
|
||||
}
|
||||
fn highpass(sample_rate: f32, fc: f32) -> Biquad {
|
||||
let w = 2.0 * std::f32::consts::PI * fc / sample_rate;
|
||||
let alpha = w.sin() * std::f32::consts::FRAC_1_SQRT_2;
|
||||
let c = w.cos();
|
||||
Biquad::new(
|
||||
(1.0 + c) / 2.0,
|
||||
-(1.0 + c),
|
||||
(1.0 + c) / 2.0,
|
||||
1.0 + alpha,
|
||||
-2.0 * c,
|
||||
1.0 - alpha,
|
||||
)
|
||||
}
|
||||
fn process(&mut self, x: f32) -> f32 {
|
||||
let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2
|
||||
- self.a1 * self.y1
|
||||
- self.a2 * self.y2;
|
||||
self.x2 = self.x1;
|
||||
self.x1 = x;
|
||||
self.y2 = self.y1;
|
||||
self.y1 = y;
|
||||
y
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal 16-bit PCM stereo WAV writer (44-byte canonical header).
|
||||
fn write_wav_16(dest: &Path, interleaved: &[f32], sample_rate: u32) -> Result<(), String> {
|
||||
let data_len = (interleaved.len() * 2) as u32;
|
||||
let mut out = std::io::BufWriter::new(
|
||||
std::fs::File::create(dest).map_err(|e| e.to_string())?,
|
||||
);
|
||||
let byte_rate = sample_rate * 2 * 2;
|
||||
let mut header = Vec::with_capacity(44);
|
||||
header.extend_from_slice(b"RIFF");
|
||||
header.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
header.extend_from_slice(b"WAVEfmt ");
|
||||
header.extend_from_slice(&16u32.to_le_bytes());
|
||||
header.extend_from_slice(&1u16.to_le_bytes()); // PCM
|
||||
header.extend_from_slice(&2u16.to_le_bytes()); // stereo
|
||||
header.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
header.extend_from_slice(&byte_rate.to_le_bytes());
|
||||
header.extend_from_slice(&4u16.to_le_bytes()); // block align
|
||||
header.extend_from_slice(&16u16.to_le_bytes()); // bits
|
||||
header.extend_from_slice(b"data");
|
||||
header.extend_from_slice(&data_len.to_le_bytes());
|
||||
out.write_all(&header).map_err(|e| e.to_string())?;
|
||||
let mut bytes = Vec::with_capacity(interleaved.len() * 2);
|
||||
for &sample in interleaved {
|
||||
bytes.extend_from_slice(&((sample.clamp(-1.0, 1.0) * 32767.0) as i16).to_le_bytes());
|
||||
}
|
||||
out.write_all(&bytes).map_err(|e| e.to_string())?;
|
||||
out.flush().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rms(stereo: &[f32]) -> f32 {
|
||||
(stereo.iter().map(|s| s * s).sum::<f32>() / stereo.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
fn sine_stereo(freq: f32, secs: f32, rate: u32, pan: &str) -> Vec<f32> {
|
||||
let frames = (secs * rate as f32) as usize;
|
||||
let mut out = Vec::with_capacity(frames * 2);
|
||||
for i in 0..frames {
|
||||
let s = (2.0 * std::f32::consts::PI * freq * i as f32 / rate as f32).sin() * 0.5;
|
||||
match pan {
|
||||
"center" => {
|
||||
out.push(s);
|
||||
out.push(s);
|
||||
}
|
||||
"left" => {
|
||||
out.push(s);
|
||||
out.push(0.0);
|
||||
}
|
||||
// Anti-phase: pure side content.
|
||||
_ => {
|
||||
out.push(s);
|
||||
out.push(-s);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The whole point: a center-panned vocal-band tone dies, a center bass
|
||||
/// tone survives, side content survives.
|
||||
#[test]
|
||||
fn center_vocal_band_is_cancelled_lows_and_sides_survive() {
|
||||
let rate = 44_100;
|
||||
let mut vocal = sine_stereo(1_000.0, 0.5, rate, "center");
|
||||
let before = rms(&vocal);
|
||||
process_center_cancel(&mut vocal, rate);
|
||||
assert!(
|
||||
rms(&vocal) < before * 0.06,
|
||||
"center 1kHz must drop >24dB, got {} → {}",
|
||||
before,
|
||||
rms(&vocal)
|
||||
);
|
||||
|
||||
let mut bass = sine_stereo(60.0, 0.5, rate, "center");
|
||||
let bass_before = rms(&bass);
|
||||
process_center_cancel(&mut bass, rate);
|
||||
assert!(
|
||||
rms(&bass) > bass_before * 0.6,
|
||||
"center 60Hz must survive the low-band keep, got {} → {}",
|
||||
bass_before,
|
||||
rms(&bass)
|
||||
);
|
||||
|
||||
let mut side = sine_stereo(1_000.0, 0.5, rate, "side");
|
||||
let side_before = rms(&side);
|
||||
process_center_cancel(&mut side, rate);
|
||||
assert!(
|
||||
rms(&side) > side_before * 0.7,
|
||||
"side content must pass through, got {} → {}",
|
||||
side_before,
|
||||
rms(&side)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onset_ignores_a_lone_tick() {
|
||||
// 40 silent frames, one loud blip, more silence, then real audio.
|
||||
let mut frames = vec![0.0005f32; 40];
|
||||
frames[10] = 0.2; // vinyl tick — one frame only
|
||||
frames.extend(vec![0.1f32; 20]);
|
||||
assert_eq!(onset_from_rms(&frames), Some(40));
|
||||
assert_eq!(onset_from_rms(&[0.0001f32; 50]), None, "never audible");
|
||||
}
|
||||
|
||||
/// End-to-end through the WAV writer and back through symphonia: the
|
||||
/// rendered file decodes, keeps the length, and is quieter in the mid.
|
||||
#[test]
|
||||
fn renders_a_playable_wav() {
|
||||
let dir = std::env::temp_dir().join("ssplus-vocals-test");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let src = dir.join("src.wav");
|
||||
let dest = dir.join("out.wav");
|
||||
let rate = 44_100u32;
|
||||
// A "song": center 1kHz vocal + left-panned 3kHz guitar.
|
||||
let mut mix = sine_stereo(1_000.0, 1.0, rate, "center");
|
||||
let guitar = sine_stereo(3_000.0, 1.0, rate, "left");
|
||||
for (m, g) in mix.iter_mut().zip(guitar.iter()) {
|
||||
*m = (*m + g).clamp(-1.0, 1.0);
|
||||
}
|
||||
write_wav_16(&src, &mix, rate).unwrap();
|
||||
|
||||
let result = render_vocal_reduced(&src, &dest).unwrap();
|
||||
assert_eq!(result.sample_rate, rate);
|
||||
assert!((result.duration_secs - 1.0).abs() < 0.05);
|
||||
|
||||
let back = decode(&dest, None).unwrap();
|
||||
assert_eq!(back.channels, 2);
|
||||
let frames = back.samples.len() / 2;
|
||||
assert!((frames as f64 / rate as f64 - 1.0).abs() < 0.05);
|
||||
|
||||
// Mono files are refused, not mangled.
|
||||
let mono = dir.join("mono.wav");
|
||||
{
|
||||
use std::io::Write as _;
|
||||
let samples: Vec<f32> = sine_stereo(440.0, 0.2, rate, "center")
|
||||
.chunks(2)
|
||||
.map(|c| c[0])
|
||||
.collect();
|
||||
let data_len = (samples.len() * 2) as u32;
|
||||
let mut out = std::fs::File::create(&mono).unwrap();
|
||||
let mut header = Vec::new();
|
||||
header.extend_from_slice(b"RIFF");
|
||||
header.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
header.extend_from_slice(b"WAVEfmt ");
|
||||
header.extend_from_slice(&16u32.to_le_bytes());
|
||||
header.extend_from_slice(&1u16.to_le_bytes());
|
||||
header.extend_from_slice(&1u16.to_le_bytes());
|
||||
header.extend_from_slice(&rate.to_le_bytes());
|
||||
header.extend_from_slice(&(rate * 2).to_le_bytes());
|
||||
header.extend_from_slice(&2u16.to_le_bytes());
|
||||
header.extend_from_slice(&16u16.to_le_bytes());
|
||||
header.extend_from_slice(b"data");
|
||||
header.extend_from_slice(&data_len.to_le_bytes());
|
||||
out.write_all(&header).unwrap();
|
||||
for s in &samples {
|
||||
out.write_all(&((s * 32767.0) as i16).to_le_bytes()).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(render_vocal_reduced(&mono, &dest).is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soulseek-proto = { path = "../soulseek-proto" }
|
||||
audio-formats = { path = "../audio-formats" }
|
||||
tokio.workspace = true
|
||||
tokio-util = "0.7"
|
||||
bytes.workspace = true
|
||||
|
||||
@@ -93,8 +93,11 @@ async fn run_parent(inner: &Arc<Inner>, mut stream: TcpStream) -> std::io::Resul
|
||||
responder::respond_to_search(inner.clone(), username, token, query);
|
||||
}
|
||||
Ok(dist::DistributedEvent::BranchLevel(level)) => {
|
||||
// Report our level (parent + 1) to the server.
|
||||
let _ = inner.send_server(server::branch_level(level + 1));
|
||||
// Report our level (parent + 1) to the server. Saturating: the
|
||||
// level is a peer-supplied u32, and one parent really did send
|
||||
// a value at the top of the range — `+ 1` panicked this worker
|
||||
// (debug overflow checks) mid-session on 2026-08-14.
|
||||
let _ = inner.send_server(server::branch_level(level.saturating_add(1)));
|
||||
}
|
||||
Ok(dist::DistributedEvent::BranchRoot(root)) => {
|
||||
let _ = inner.send_server(server::branch_root(&root));
|
||||
|
||||
@@ -11,9 +11,10 @@ use std::sync::RwLock;
|
||||
|
||||
use soulseek_proto::types::{Directory, File};
|
||||
|
||||
const AUDIO_EXTS: [&str; 10] = [
|
||||
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
|
||||
];
|
||||
// Shared with the library scanner and the row pruner (`audio-formats`). The
|
||||
// share index deliberately offers formats this app cannot itself PLAY — a peer
|
||||
// asking for `.ape` is well served by a file we hold, and refusing to share it
|
||||
// helps nobody.
|
||||
|
||||
/// One shared file: its remote (virtual) path and where it lives locally.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -161,8 +162,14 @@ fn walk(dir: &Path, base: &str, root: &Path, index: &mut Index) {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
// The download staging pen is not part of anyone's library —
|
||||
// half-finished transfers are not shareable records.
|
||||
if path.file_name().is_some_and(|n| n == "(Cache)") {
|
||||
// half-finished transfers are not shareable records. `.soulstream`
|
||||
// holds the library's own database, review session and artwork
|
||||
// cache; only audio is ever indexed, so it could not be served
|
||||
// anyway, but it has no business appearing in a browse listing.
|
||||
if path
|
||||
.file_name()
|
||||
.is_some_and(|n| n == "(Cache)" || n == ".soulstream")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
walk(&path, base, root, index);
|
||||
@@ -203,10 +210,7 @@ fn to_remote_path(path: &Path, base: &str, root: &Path) -> String {
|
||||
}
|
||||
|
||||
fn is_audio(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
|
||||
.unwrap_or(false)
|
||||
audio_formats::is_audio_path(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -26,6 +26,10 @@ pub struct TagSummary {
|
||||
pub album: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub track_no: Option<u32>,
|
||||
/// Which disc of the release, 1-based, when the file says so. Every disc
|
||||
/// numbers its tracks from 1, so without this a two-disc rip reads as two
|
||||
/// track 1s, two track 2s, and so on down the album.
|
||||
pub disc_no: Option<u32>,
|
||||
pub year: Option<u32>,
|
||||
pub duration_secs: Option<u32>,
|
||||
pub bitrate_kbps: Option<u32>,
|
||||
@@ -50,6 +54,7 @@ pub fn read_summary(path: &Path) -> Result<TagSummary> {
|
||||
summary.album = non_empty(tag.album().as_deref());
|
||||
summary.title = non_empty(tag.title().as_deref());
|
||||
summary.track_no = tag.track();
|
||||
summary.disc_no = tag.disk();
|
||||
summary.year = tag.year();
|
||||
summary.album_artist = non_empty(
|
||||
tag.get_string(&lofty::tag::ItemKey::AlbumArtist)
|
||||
@@ -69,6 +74,10 @@ pub struct TagWrite {
|
||||
pub album: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub track_no: Option<u32>,
|
||||
/// Written into the file, not just the database: a release joined from
|
||||
/// disc folders loses that structure the moment it is filed flat, and the
|
||||
/// tag is the only place the answer survives being read by anything else.
|
||||
pub disc_no: Option<u32>,
|
||||
pub year: Option<u32>,
|
||||
pub label: Option<String>,
|
||||
pub genre: Option<String>,
|
||||
@@ -109,6 +118,7 @@ pub fn write_tags(
|
||||
|| write.album.is_some()
|
||||
|| write.title.is_some()
|
||||
|| write.track_no.is_some()
|
||||
|| write.disc_no.is_some()
|
||||
|| write.year.is_some()
|
||||
|| write.label.is_some()
|
||||
|| write.genre.is_some();
|
||||
@@ -147,6 +157,9 @@ pub fn write_tags(
|
||||
if let Some(v) = write.track_no {
|
||||
tag.set_track(v);
|
||||
}
|
||||
if let Some(v) = write.disc_no {
|
||||
tag.set_disk(v);
|
||||
}
|
||||
if let Some(v) = write.year {
|
||||
tag.set_year(v);
|
||||
}
|
||||
@@ -222,6 +235,104 @@ pub fn replace_art(path: &Path, art_jpeg: &[u8]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lyrics carried inside the audio file itself.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EmbeddedLyrics {
|
||||
/// Unsynchronized text (ID3 USLT or the generic LYRICS field).
|
||||
pub plain: Option<String>,
|
||||
/// Synchronized lines from an ID3v2 SYLT frame, (milliseconds, text),
|
||||
/// sorted by time. Empty when the file has none.
|
||||
pub synced: Vec<(u32, String)>,
|
||||
}
|
||||
|
||||
/// Read embedded lyrics. The plain field comes from the generic tag on every
|
||||
/// format lofty knows; SYLT lives only in ID3v2, which lofty exposes as a
|
||||
/// binary frame on the concrete MPEG file type, so that pass is MP3-only.
|
||||
pub fn read_lyrics(path: &Path) -> Result<EmbeddedLyrics> {
|
||||
use lofty::id3::v2::{Frame, SynchronizedTextFrame, TimestampFormat};
|
||||
use lofty::prelude::*;
|
||||
|
||||
let mut out = EmbeddedLyrics::default();
|
||||
let tagged = Probe::open(path)?.read()?;
|
||||
if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) {
|
||||
out.plain = non_empty(
|
||||
tag.get_string(&lofty::tag::ItemKey::Lyrics)
|
||||
.map(|s| s.to_string())
|
||||
.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
let is_mp3 = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|e| e.eq_ignore_ascii_case("mp3"));
|
||||
if is_mp3 {
|
||||
if let Ok(mut file) = std::fs::File::open(path) {
|
||||
if let Ok(mpeg) = lofty::mpeg::MpegFile::read_from(
|
||||
&mut file,
|
||||
lofty::config::ParseOptions::new(),
|
||||
) {
|
||||
if let Some(id3) = mpeg.id3v2() {
|
||||
for frame in id3 {
|
||||
let Frame::Binary(binary) = frame else { continue };
|
||||
if frame.id().as_str() != "SYLT" {
|
||||
continue;
|
||||
}
|
||||
let Ok(sylt) = SynchronizedTextFrame::parse(
|
||||
&binary.data,
|
||||
lofty::id3::v2::FrameFlags::default(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
// MPEG-frame-counted timestamps cannot be turned into
|
||||
// time without decoding; only millisecond SYLT is usable.
|
||||
if sylt.timestamp_format != TimestampFormat::MS {
|
||||
continue;
|
||||
}
|
||||
let mut lines: Vec<(u32, String)> = sylt
|
||||
.content
|
||||
.into_iter()
|
||||
.map(|(ms, text)| (ms, text.trim().to_string()))
|
||||
.collect();
|
||||
lines.sort_by_key(|(ms, _)| *ms);
|
||||
if lines.iter().any(|(_, t)| !t.is_empty()) {
|
||||
out.synced = lines;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The file's own ISRC tag, normalized to the bare 12-character form
|
||||
/// (CC-XXX-YY-NNNNN with the dashes people sometimes write stripped), or
|
||||
/// None when absent or malformed. An ISRC names the exact RECORDING, which
|
||||
/// makes it the one tag strong enough to match against external catalogs
|
||||
/// without touching names.
|
||||
pub fn read_isrc(path: &Path) -> Result<Option<String>> {
|
||||
let tagged = Probe::open(path)?.read()?;
|
||||
let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let raw = tag
|
||||
.get_string(&lofty::tag::ItemKey::Isrc)
|
||||
.map(|s| s.to_string());
|
||||
Ok(raw.and_then(|s| {
|
||||
let normalized: String = s
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.map(|c| c.to_ascii_uppercase())
|
||||
.collect();
|
||||
let shaped = normalized.len() == 12
|
||||
&& normalized.chars().take(2).all(|c| c.is_ascii_alphabetic())
|
||||
&& normalized.chars().skip(5).all(|c| c.is_ascii_digit());
|
||||
shaped.then_some(normalized)
|
||||
}))
|
||||
}
|
||||
|
||||
fn non_empty(s: Option<&str>) -> Option<String> {
|
||||
s.map(str::trim).filter(|s| !s.is_empty()).map(String::from)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user