Merge pull request #81 from michel/lib/audio-attributes

feat(lib): read audio attributes from shared files at scan time
This commit is contained in:
Michel de Graaf
2026-09-08 02:06:22 +02:00
committed by GitHub
4 changed files with 453 additions and 3 deletions
+335
View File
@@ -0,0 +1,335 @@
//! The audio attributes a share advertises for a file, read from the file's
//! own headers: Soulseek codes 0 bitrate (kbps), 1 duration (seconds), 2 VBR,
//! 4 sample rate (Hz) and 5 bit depth. Searchers filter and sort on these, so
//! a file without them ranks last in every other client.
use std::path::Path;
use std::io::{Read, Seek, SeekFrom};
const BITRATE: u32 = 0;
const DURATION: u32 = 1;
const VBR: u32 = 2;
const SAMPLE_RATE: u32 = 4;
const BIT_DEPTH: u32 = 5;
/// How much of a file's head one read takes. A frame header with its Xing
/// block needs under a hundred bytes; the rest is margin so a typical ID3v2
/// tag of a few kilobytes fits in the first read too.
const HEAD: usize = 8 * 1024;
/// The attributes for `path`, or none when the format is not one we read or
/// the file does not parse. Only the file's head is read.
#[must_use]
pub fn probe(path: &Path) -> Vec<(u32, u32)> {
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase());
match ext.as_deref() {
Some("mp3") => mp3(path),
Some("flac") => flac(path),
_ => None,
}
.unwrap_or_default()
}
fn flac(path: &Path) -> Option<Vec<(u32, u32)>> {
let mut head = [0u8; 42];
std::fs::File::open(path).ok()?.read_exact(&mut head).ok()?;
if &head[0..4] != b"fLaC" || head[4] & 0x7F != 0 {
return None;
}
let packed = u64::from_be_bytes(head[18..26].try_into().ok()?);
let sample_rate = (packed >> 44) as u32;
let bits = ((packed >> 36) & 0x1F) as u32 + 1;
let samples = packed & 0xF_FFFF_FFFF;
if sample_rate == 0 {
return None;
}
let mut attributes = Vec::new();
if samples > 0 {
let seconds = samples / u64::from(sample_rate);
attributes.push((DURATION, u32::try_from(seconds).unwrap_or(u32::MAX)));
}
attributes.push((SAMPLE_RATE, sample_rate));
attributes.push((BIT_DEPTH, bits));
Some(attributes)
}
/// Layer III bitrates in kbps by the header's four-bit index, for MPEG-1 and
/// for MPEG-2 and 2.5, which halve the table.
const MPEG1_BITRATES: [u32; 15] = [
0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320,
];
const MPEG2_BITRATES: [u32; 15] =
[0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160];
/// Sample rates by the header's two-bit index, one row per version: MPEG-2.5,
/// MPEG-2, MPEG-1.
const SAMPLE_RATES: [[u32; 3]; 3] = [
[11_025, 12_000, 8_000],
[22_050, 24_000, 16_000],
[44_100, 48_000, 32_000],
];
/// One Layer III frame header, decoded.
struct Frame {
bitrate_kbps: u32,
sample_rate: u32,
samples: u32,
/// Offset of the Xing/Info block relative to the header.
xing_at: usize,
}
fn frame(h: &[u8]) -> Option<Frame> {
if h.len() < 4 || h[0] != 0xFF || h[1] & 0xE0 != 0xE0 {
return None;
}
let version = (h[1] >> 3) & 3; // 0 = 2.5, 2 = 2, 3 = 1
let layer = (h[1] >> 1) & 3; // 1 = Layer III
let bitrate_index = usize::from(h[2] >> 4);
let rate_index = usize::from((h[2] >> 2) & 3);
if version == 1
|| layer != 1
|| bitrate_index == 0
|| bitrate_index == 15
|| rate_index == 3
{
return None;
}
let mpeg1 = version == 3;
let mono = (h[3] >> 6) & 3 == 3;
// Protection bit clear means a two-byte CRC precedes the side info.
let crc = usize::from(h[1] & 1 == 0) * 2;
let rates = SAMPLE_RATES[if version == 0 {
0
} else if mpeg1 {
2
} else {
1
}];
Some(Frame {
bitrate_kbps: if mpeg1 {
MPEG1_BITRATES
} else {
MPEG2_BITRATES
}[bitrate_index],
sample_rate: rates[rate_index],
samples: if mpeg1 { 1152 } else { 576 },
// Side info is 32 bytes for MPEG-1 stereo, 17 for MPEG-1 mono and
// MPEG-2 stereo, 9 for MPEG-2 mono.
xing_at: 4
+ crc
+ match (mpeg1, mono) {
(true, false) => 32,
(true, true) | (false, false) => 17,
(false, true) => 9,
},
})
}
fn mp3(path: &Path) -> Option<Vec<(u32, u32)>> {
let mut file = std::fs::File::open(path).ok()?;
let total = file.metadata().ok()?.len();
let mut head = [0u8; HEAD];
let mut len = file.read(&mut head).ok()?;
// An ID3v2 tag in front: its size is four syncsafe bytes, plus a footer.
// `base` is where `head` sits in the file once a big tag forces a second
// read, so the audio length below counts from the first frame.
let mut base = 0usize;
let mut audio_at = 0usize;
if len >= 10 && &head[0..3] == b"ID3" {
let size = head[6..10]
.iter()
.fold(0usize, |acc, &b| (acc << 7) | usize::from(b & 0x7F));
audio_at = 10 + size + if head[5] & 0x10 != 0 { 10 } else { 0 };
if audio_at + 4 > len {
file.seek(SeekFrom::Start(audio_at as u64)).ok()?;
len = file.read(&mut head).ok()?;
base = audio_at;
audio_at = 0;
}
}
let head = &head[..len];
let start = (audio_at..len.saturating_sub(4))
.find(|&i| frame(&head[i..]).is_some())?;
let frame = frame(&head[start..])?;
let audio_bytes = total.saturating_sub((base + start) as u64);
let at_frame_rate = |bytes: u64| {
(bytes * 8 / (u64::from(frame.bitrate_kbps) * 1000)) as u32
};
let xing = start + frame.xing_at;
let tag = head.get(xing..xing + 4);
let vbr = u32::from(tag == Some(b"Xing"));
if tag != Some(b"Xing") && tag != Some(b"Info") {
return Some(vec![
(BITRATE, frame.bitrate_kbps),
(DURATION, at_frame_rate(audio_bytes)),
(VBR, 0),
]);
}
let flags =
u32::from_be_bytes(head.get(xing + 4..xing + 8)?.try_into().ok()?);
let mut at = xing + 8;
let mut frames = None;
if flags & 1 != 0 {
frames =
Some(u32::from_be_bytes(head.get(at..at + 4)?.try_into().ok()?));
at += 4;
}
let bytes = if flags & 2 != 0 {
u64::from(u32::from_be_bytes(head.get(at..at + 4)?.try_into().ok()?))
} else {
audio_bytes
};
// Without a frame count the block adds nothing the first frame did not say.
let Some(frames) = frames else {
return Some(vec![
(BITRATE, frame.bitrate_kbps),
(DURATION, at_frame_rate(bytes)),
(VBR, vbr),
]);
};
let seconds = f64::from(frames) * f64::from(frame.samples)
/ f64::from(frame.sample_rate);
let kbps = if seconds > 0.0 {
(bytes as f64 * 8.0 / seconds / 1000.0) as u32
} else {
frame.bitrate_kbps
};
Some(vec![
(BITRATE, kbps),
(DURATION, seconds as u32),
(VBR, vbr),
])
}
#[cfg(test)]
pub(in crate::shares) mod tests {
use super::*;
/// One MPEG-1 Layer III frame header: 128 kbps, 44.1 kHz, stereo.
const CBR_128: [u8; 4] = [0xFF, 0xFB, 0x90, 0x00];
const CBR_128_FRAME_LEN: usize = 417;
pub(in crate::shares) fn cbr_mp3(frames: usize) -> Vec<u8> {
let mut out = Vec::new();
for _ in 0..frames {
out.extend_from_slice(&CBR_128);
out.resize(out.len() + CBR_128_FRAME_LEN - 4, 0);
}
out
}
fn write(name: &str, bytes: &[u8]) -> std::path::PathBuf {
let dir = std::env::temp_dir()
.join(format!("soulseek-audio-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path
}
#[test]
fn a_constant_bitrate_mp3_reports_bitrate_duration_and_no_vbr() {
// 100 frames of 1152 samples at 44.1 kHz is 2.61 s.
let path = write("cbr.mp3", &cbr_mp3(100));
assert_eq!(probe(&path), [(0, 128), (1, 2), (2, 0)]);
}
/// An ID3v2 tag of `size` bytes (syncsafe in the header) with no footer.
fn id3v2(size: usize) -> Vec<u8> {
let mut tag = b"ID3\x04\x00\x00".to_vec();
tag.push(((size >> 21) & 0x7F) as u8);
tag.push(((size >> 14) & 0x7F) as u8);
tag.push(((size >> 7) & 0x7F) as u8);
tag.push((size & 0x7F) as u8);
tag.resize(10 + size, 0);
tag
}
#[test]
fn an_id3v2_tag_in_front_of_the_frames_is_skipped() {
// 50 frames is 1.31 s; counting the 8000-byte tag as audio, or the
// tag twice, would put the duration at 0.
let mut bytes = id3v2(8000);
bytes.extend_from_slice(&cbr_mp3(50));
let path = write("tagged.mp3", &bytes);
assert_eq!(probe(&path), [(0, 128), (1, 1), (2, 0)]);
}
#[test]
fn a_tag_larger_than_the_first_read_is_skipped_too() {
// 20 KiB of tag pushes the frames past the first read; 100 frames is
// 2.61 s, and counting the tag as audio would say 3.
let mut bytes = id3v2(20_480);
bytes.extend_from_slice(&cbr_mp3(100));
let path = write("big-tag.mp3", &bytes);
assert_eq!(probe(&path), [(0, 128), (1, 2), (2, 0)]);
}
#[test]
fn a_crc_protected_frame_still_finds_its_xing_header() {
// Protection bit clear: a two-byte CRC sits before the side info.
let mut bytes = cbr_mp3(1);
bytes[1] = 0xFA;
let xing_at = 4 + 2 + 32;
bytes[xing_at..xing_at + 4].copy_from_slice(b"Xing");
bytes[xing_at + 4..xing_at + 8].copy_from_slice(&3u32.to_be_bytes());
bytes[xing_at + 8..xing_at + 12].copy_from_slice(&200u32.to_be_bytes());
bytes[xing_at + 12..xing_at + 16]
.copy_from_slice(&104_250u32.to_be_bytes());
let path = write("crc.mp3", &bytes);
assert_eq!(probe(&path), [(0, 159), (1, 5), (2, 1)]);
}
#[test]
fn a_xing_header_without_a_frame_count_falls_back_to_the_frame_header() {
let mut bytes = cbr_mp3(100);
let xing_at = 4 + 32;
bytes[xing_at..xing_at + 4].copy_from_slice(b"Xing");
bytes[xing_at + 4..xing_at + 8].copy_from_slice(&2u32.to_be_bytes());
bytes[xing_at + 8..xing_at + 12]
.copy_from_slice(&41_700u32.to_be_bytes());
let path = write("no-frames.mp3", &bytes);
assert_eq!(probe(&path), [(0, 128), (1, 2), (2, 1)]);
}
#[test]
fn a_variable_bitrate_mp3_uses_its_xing_header() {
// A Xing header in the first frame's data: 200 frames, 104_250 bytes
// in the stream, so 5.22 s at an average of 160 kbps.
let mut bytes = cbr_mp3(1);
let xing_at = 4 + 32; // MPEG-1 stereo side info
bytes[xing_at..xing_at + 4].copy_from_slice(b"Xing");
bytes[xing_at + 4..xing_at + 8].copy_from_slice(&3u32.to_be_bytes());
bytes[xing_at + 8..xing_at + 12].copy_from_slice(&200u32.to_be_bytes());
bytes[xing_at + 12..xing_at + 16]
.copy_from_slice(&104_250u32.to_be_bytes());
let path = write("vbr.mp3", &bytes);
assert_eq!(probe(&path), [(0, 159), (1, 5), (2, 1)]);
}
#[test]
fn a_flac_reports_duration_sample_rate_and_bit_depth() {
// STREAMINFO: 44.1 kHz, 2 channels, 16 bits, 441_000 samples (10 s).
let mut bytes = b"fLaC".to_vec();
bytes.extend_from_slice(&[0x80, 0x00, 0x00, 0x22]);
bytes.extend_from_slice(&[0x10, 0x00, 0x10, 0x00]); // block sizes
bytes.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // frame sizes
let packed: u64 =
(44_100u64 << 44) | (1u64 << 41) | (15u64 << 36) | 0x0006_BAA8;
bytes.extend_from_slice(&packed.to_be_bytes());
bytes.extend_from_slice(&[0u8; 16]); // md5
let path = write("lossless.flac", &bytes);
assert_eq!(probe(&path), [(1, 10), (4, 44_100), (5, 16)]);
}
#[test]
fn anything_else_or_a_truncated_file_has_no_attributes() {
assert!(probe(&write("notes.txt", b"hello")).is_empty());
assert!(probe(&write("short.mp3", b"ID3")).is_empty());
assert!(probe(&write("short.flac", b"fLaC\x80\x00")).is_empty());
assert!(probe(Path::new("/nonexistent/x.mp3")).is_empty());
}
}
+38 -2
View File
@@ -4,6 +4,8 @@
//! peer-facing *virtual path* (the shared directory's own name followed by the //! peer-facing *virtual path* (the shared directory's own name followed by the
//! backslash-separated relative path, matching the Soulseek wire convention). //! backslash-separated relative path, matching the Soulseek wire convention).
mod audio;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -19,7 +21,8 @@ pub struct SharedFile {
/// The real filesystem path used to serve the bytes. /// The real filesystem path used to serve the bytes.
pub real_path: PathBuf, pub real_path: PathBuf,
pub size: u64, pub size: u64,
/// Optional `(code, value)` audio attributes (empty for now). /// `(code, value)` audio attributes read from the file's headers: bitrate,
/// duration, VBR, sample rate, bit depth, as far as the format gives them.
pub attributes: Vec<(u32, u32)>, pub attributes: Vec<(u32, u32)>,
} }
@@ -202,9 +205,9 @@ fn scan_root(
files.push(SharedFile { files.push(SharedFile {
search_key: virtual_path.to_lowercase(), search_key: virtual_path.to_lowercase(),
virtual_path, virtual_path,
attributes: audio::probe(&path),
real_path: path, real_path: path,
size: meta.len(), size: meta.len(),
attributes: Vec::new(),
}); });
folders_with_files.insert(dir.clone()); folders_with_files.insert(dir.clone());
} }
@@ -244,6 +247,39 @@ mod tests {
root root
} }
#[test]
fn a_scanned_mp3_carries_its_bitrate() {
let root = std::env::temp_dir()
.join(format!("soulseek-shares-audio-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
// 100 frames at 128 kbps is 2.6 seconds.
std::fs::write(
root.join("track.mp3"),
super::audio::tests::cbr_mp3(100),
)
.unwrap();
std::fs::write(root.join("cover.jpg"), b"jpeg").unwrap();
let shares = Shares::scan(&root).unwrap();
let track = shares
.get(
"soulseek-shares-audio-{pid}\\track.mp3"
.replace("{pid}", &std::process::id().to_string())
.as_str(),
)
.unwrap();
assert!(track.attributes.contains(&(0, 128)));
assert!(track.attributes.contains(&(1, 2)));
let cover = shares
.files()
.iter()
.find(|f| f.virtual_path.ends_with("cover.jpg"))
.unwrap();
assert!(cover.attributes.is_empty());
let _ = std::fs::remove_dir_all(root);
}
#[test] #[test]
fn scan_counts_files_and_folders() { fn scan_counts_files_and_folders() {
let root = temp_tree(); let root = temp_tree();
+40
View File
@@ -3532,3 +3532,43 @@ fn an_unanswered_upload_offer_frees_its_slot() {
let _ = std::fs::remove_dir_all(share); let _ = std::fs::remove_dir_all(share);
} }
/// A constant-bitrate MP3: `frames` MPEG-1 Layer III frames at 128 kbps,
/// 44.1 kHz, so every searcher sees a bitrate to filter and sort on.
fn cbr_mp3(frames: usize) -> Vec<u8> {
let mut out = Vec::new();
for _ in 0..frames {
out.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
out.resize(out.len() + 413, 0);
}
out
}
// A shared file with no bitrate fails every `--min-bitrate` filter and sorts
// last in SoulseekQt; the attributes come from the file's own headers.
#[test]
fn a_shared_mp3_advertises_its_bitrate_and_duration() {
let server = server_or_skip!();
let share_dir = unique_download_dir();
// 1000 frames is 26 seconds.
std::fs::write(share_dir.join("attrprobe song.mp3"), cbr_mp3(1000))
.unwrap();
let (_sharer, searcher) = sharer_and_searcher(
&server,
&share_dir,
"e2e_attr_sharer",
"e2e_attr_searcher",
);
let query = "attrprobe";
let _ = searcher.search(query, Duration::from_secs(3));
let reply = reply_from(&searcher, query, "e2e_attr_sharer")
.expect("the sharer answers");
let file = &reply.files[0];
assert_eq!(file.attribs.get(&0), Some(&128), "bitrate in kbps");
assert_eq!(file.attribs.get(&1), Some(&26), "duration in seconds");
assert_eq!(file.attribs.get(&2), Some(&0), "constant bitrate");
let _ = std::fs::remove_dir_all(share_dir);
}
+40 -1
View File
@@ -1084,8 +1084,11 @@ impl FilterFixture {
fn new(server: &TestServer, sharer: &str) -> (Self, Client) { fn new(server: &TestServer, sharer: &str) -> (Self, Client) {
let share = Scratch::new("filter-share"); let share = Scratch::new("filter-share");
// The mp3 is a real 128 kbps stream so the bitrate filter has
// something to read; the others are padding of a known size.
std::fs::write(share.path().join("flt_alpha_keepme.mp3"), cbr_mp3(10))
.expect("share file");
for (name, size) in [ for (name, size) in [
("flt_alpha_keepme.mp3", Self::MEDIUM),
("flt_beta_skipme.flac", Self::SMALL), ("flt_beta_skipme.flac", Self::SMALL),
("flt_gamma_keepme.ogg", Self::LARGE), ("flt_gamma_keepme.ogg", Self::LARGE),
] { ] {
@@ -1098,6 +1101,18 @@ impl FilterFixture {
} }
} }
/// A constant-bitrate MP3: `frames` MPEG-1 Layer III frames at 128 kbps and
/// 44.1 kHz, 417 bytes each; ten of them land between the fixture's SMALL
/// and LARGE sizes.
fn cbr_mp3(frames: usize) -> Vec<u8> {
let mut out = Vec::new();
for _ in 0..frames {
out.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
out.resize(out.len() + 413, 0);
}
out
}
/// Search for the fixture as a fresh user and return the matching file names, /// Search for the fixture as a fresh user and return the matching file names,
/// sorted so the assertion does not depend on result order. /// sorted so the assertion does not depend on result order.
/// ///
@@ -1155,6 +1170,30 @@ fn extension_keeps_one_type_and_repeats_into_a_union() {
assert_eq!(several, ["flt_beta_skipme.flac", "flt_gamma_keepme.ogg"]); assert_eq!(several, ["flt_beta_skipme.flac", "flt_gamma_keepme.ogg"]);
} }
#[test]
fn a_bitrate_floor_keeps_only_files_that_advertise_at_least_that_much() {
let server = server_or_skip!();
let (_fixture, _sharer) = FilterFixture::new(&server, "cli_e2e_flt_kbps");
// Only the mp3 carries a bitrate; the padding files have none and an
// unknown bitrate cannot satisfy a floor.
let (exit, enough) = filtered_names(
&server,
"cli_e2e_seek_kbps_128",
&["--min-bitrate", "128"],
);
assert_eq!(exit, EXIT_OK);
assert_eq!(enough, ["flt_alpha_keepme.mp3"]);
let (exit, none) = filtered_names(
&server,
"cli_e2e_seek_kbps_320",
&["--min-bitrate", "320"],
);
assert_eq!(exit, EXIT_NO_RESULTS);
assert!(none.is_empty());
}
#[test] #[test]
fn size_bounds_narrow_the_result_set_from_either_end() { fn size_bounds_narrow_the_result_set_from_either_end() {
let server = server_or_skip!(); let server = server_or_skip!();