commit b2fd8f182d36836b3041cc710a6545e88e0f4bd4 from: mtmn date: Mon Jul 27 13:19:42 2026 UTC report tempo confidence and key strength commit - 0cc756c5727a93adc29fedbcab77cfefea7724fa commit + b2fd8f182d36836b3041cc710a6545e88e0f4bd4 blob - 4075fb24e83adb9df763f0eaa25b60c73b1c0a37 blob + 9ea5b7226a083bcda854477e9cd265361ff484fb --- .gitignore +++ .gitignore @@ -1,2 +1,4 @@ target result +zilinaplayed +essentia blob - 97df38831b5402d041b70c32b12fc7c768090ced blob + f63663e8f03aabdbc0f8102cb7a463969528e342 --- src/audio_processing.rs +++ src/audio_processing.rs @@ -11,8 +11,8 @@ use std::path::Path; use std::sync::{LazyLock, Mutex}; use anyhow::{Context, Result}; -use essentia::GetFromDataContainer; use essentia::Essentia; +use essentia::GetFromDataContainer; use essentia::algorithm::input_output::MonoLoader; use essentia::algorithm::rhythm::{RhythmExtractor2013, RhythmExtractor2013Method}; use essentia::algorithm::tonal::{KeyExtractor, KeyExtractorProfileType}; blob - 78b15a03638e0d3f6c0be7944515113de4dde540 blob + 3b22eaf2e1d12ceb691e3a77aadea3874493efc9 --- src/db.rs +++ src/db.rs @@ -14,8 +14,44 @@ pub struct TrackReport { pub key: Option, #[serde(default)] pub timestamp: Option, + /// Essentia's tempo confidence. `None` for records written before this field + /// existed, for Mixxx imports, and for tracks read from existing file tags. + #[serde(default)] + pub bpm_confidence: Option, + /// Essentia's key correlation strength. `None` for the same reasons. + #[serde(default)] + pub key_strength: Option, } +/// `TrackReport` as it was stored before the confidence fields were added. +/// +/// postcard is not self-describing: a struct is just its fields back to back, +/// with no names or count, so `#[serde(default)]` cannot rescue a record that +/// is simply missing the trailing bytes. Decoding an old record as the current +/// struct hits EOF, so `deserialize_report` falls back to this shape. +#[derive(Deserialize)] +struct LegacyTrackReport { + artist: Option, + track: Option, + bpm: Option, + key: Option, + timestamp: Option, +} + +impl From for TrackReport { + fn from(old: LegacyTrackReport) -> Self { + Self { + artist: old.artist, + track: old.track, + bpm: old.bpm, + key: old.key, + timestamp: old.timestamp, + bpm_confidence: None, + key_strength: None, + } + } +} + impl fmt::Display for TrackReport { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let bpm = self @@ -25,7 +61,14 @@ impl fmt::Display for TrackReport { let key = self.key.as_deref().unwrap_or("-"); let artist = self.artist.as_deref().unwrap_or("Unknown"); let track = self.track.as_deref().unwrap_or("Unknown"); - write!(f, "{artist} - {track} | BPM: {bpm} | Key: {key}") + write!(f, "{artist} - {track} | BPM: {bpm} | Key: {key}")?; + if let Some(conf) = self.bpm_confidence { + write!(f, " | Tempo conf: {conf:.2}")?; + } + if let Some(strength) = self.key_strength { + write!(f, " | Key str: {strength:.2}")?; + } + Ok(()) } } @@ -98,8 +141,17 @@ fn serialize_report(report: &TrackReport) -> Result Result { - postcard::from_bytes(bytes).context("Failed to deserialize track report") + match postcard::from_bytes::(bytes) { + Ok(report) => Ok(report), + Err(current_err) => postcard::from_bytes::(bytes) + .map(TrackReport::from) + .map_err(|_| current_err) + .context("Failed to deserialize track report"), + } } fn effective_limit(limit: usize) -> usize { @@ -496,6 +548,8 @@ mod tests { bpm: Some(120.5), key: Some("A Minor".to_string()), timestamp: Some(1700), + bpm_confidence: Some(3.87), + key_strength: Some(0.79), }; let bytes = serialize_report(&report).unwrap(); let back = deserialize_report(&bytes).unwrap(); @@ -504,9 +558,46 @@ mod tests { assert_eq!(back.bpm, report.bpm); assert_eq!(back.key, report.key); assert_eq!(back.timestamp, report.timestamp); + assert_eq!(back.bpm_confidence, report.bpm_confidence); + assert_eq!(back.key_strength, report.key_strength); } #[test] + fn deserialize_reads_records_written_before_confidence_fields() { + // Exactly what an older virittaa wrote: the same fields, nothing after + // the timestamp. postcard has no field names or count, so this must be + // decoded by falling back to the legacy layout. + let legacy = LegacyTrackReport { + artist: Some("Aphex Twin".to_string()), + track: Some("Xtal".to_string()), + bpm: Some(120.5), + key: Some("A Minor".to_string()), + timestamp: Some(1700), + }; + let bytes = postcard::to_allocvec(&( + &legacy.artist, + &legacy.track, + &legacy.bpm, + &legacy.key, + &legacy.timestamp, + )) + .unwrap(); + + let back = deserialize_report(&bytes).unwrap(); + assert_eq!(back.artist.as_deref(), Some("Aphex Twin")); + assert_eq!(back.bpm, Some(120.5)); + assert_eq!(back.key.as_deref(), Some("A Minor")); + assert_eq!(back.timestamp, Some(1700)); + assert_eq!(back.bpm_confidence, None); + assert_eq!(back.key_strength, None); + } + + #[test] + fn deserialize_rejects_garbage() { + assert!(deserialize_report(&[0xff; 8]).is_err()); + } + + #[test] fn display_formats_full_and_empty_reports() { let full = TrackReport { artist: Some("Aphex Twin".to_string()), @@ -514,10 +605,12 @@ mod tests { bpm: Some(120.04), key: Some("A Minor".to_string()), timestamp: Some(1700), + bpm_confidence: Some(3.871), + key_strength: Some(0.79), }; assert_eq!( full.to_string(), - "Aphex Twin - Xtal | BPM: 120.0 | Key: A Minor" + "Aphex Twin - Xtal | BPM: 120.0 | Key: A Minor | Tempo conf: 3.87 | Key str: 0.79" ); let empty = TrackReport { @@ -526,6 +619,8 @@ mod tests { bpm: None, key: None, timestamp: None, + bpm_confidence: None, + key_strength: None, }; assert_eq!(empty.to_string(), "Unknown - Unknown | BPM: - | Key: -"); } @@ -541,6 +636,8 @@ mod tests { bpm: Some(bpm), key: Some(key.to_string()), timestamp: Some(ts), + bpm_confidence: None, + key_strength: None, }, ) } @@ -649,6 +746,8 @@ mod tests { bpm: Some(120.0), key: Some("C Major".to_string()), timestamp: Some(1), + bpm_confidence: None, + key_strength: None, }; db.save(&key, &first).unwrap(); @@ -659,6 +758,8 @@ mod tests { bpm: Some(128.0), key: Some("A Minor".to_string()), timestamp: Some(2), + bpm_confidence: Some(4.1), + key_strength: Some(0.66), }; db.save(&key, &second).unwrap(); blob - 5876ba6c9855098f79cb635d5fe7dc4821f695e0 blob + d2da6d6da80c6acdf70e8fb443c728036a4ae173 --- src/file_io.rs +++ src/file_io.rs @@ -18,16 +18,47 @@ pub struct FileMetadata { pub track: Option, } -fn build_track_info(path: &Path, metadata: FileMetadata, bpm: f32, key: String) -> TrackInfo { +fn build_track_info( + path: &Path, + metadata: FileMetadata, + bpm: f32, + key: String, + bpm_confidence: Option, + key_strength: Option, +) -> TrackInfo { TrackInfo { path: path.to_path_buf(), artist: metadata.artist, track: metadata.track, bpm, key, + bpm_confidence, + key_strength, } } +/// Render Essentia's tempo confidence for the console. +/// +/// `RhythmExtractor2013`'s multifeature method reports roughly 0..5.3, where +/// Essentia treats below 1.5 as unreliable and above 3.5 as strong. Flagging the +/// weak estimates is the point: octave errors and ambiguous tracks land there. +fn format_confidence(bpm_confidence: Option, key_strength: Option) -> String { + let Some(conf) = bpm_confidence else { + return String::new(); + }; + let verdict = if conf < 1.5 { + "weak" + } else if conf < 3.5 { + "fair" + } else { + "strong" + }; + key_strength.map_or_else( + || format!(" (tempo {conf:.2} {verdict})"), + |strength| format!(" (tempo {conf:.2} {verdict}, key {strength:.2})"), + ) +} + /// Process a single file, detecting BPM and key. /// /// # Errors @@ -43,20 +74,33 @@ pub fn process_file(path: &Path, write_tags: bool, for bpm, key ); - return Ok(build_track_info(path, metadata, bpm, key)); + return Ok(build_track_info(path, metadata, bpm, key, None, None)); } let analysis = analyze_file(path).map_err(|e| e.to_string())?; let bpm = analysis.bpm; let key = analysis.key; + let bpm_confidence = Some(analysis.bpm_confidence); + let key_strength = Some(analysis.key_strength); - println!("File: {}, BPM: {bpm:.1}, Key: {key}", path.display()); + println!( + "File: {}, BPM: {bpm:.1}, Key: {key}{}", + path.display(), + format_confidence(bpm_confidence, key_strength) + ); if write_tags && let Err(e) = write_metadata(path, bpm, &key) { eprintln!("Error writing metadata for {}: {e}", path.display()); } - Ok(build_track_info(path, metadata, bpm, key)) + Ok(build_track_info( + path, + metadata, + bpm, + key, + bpm_confidence, + key_strength, + )) } fn read_tags(path: &Path) -> (FileMetadata, Option<(f32, String)>) { @@ -288,6 +332,8 @@ mod tests { metadata, 120.5, "A Minor".to_string(), + Some(3.9), + Some(0.81), ); assert_eq!(info.path, Path::new("/music/xtal.flac")); @@ -295,9 +341,26 @@ mod tests { assert_eq!(info.track.as_deref(), Some("Xtal")); assert!((info.bpm - 120.5).abs() < f32::EPSILON); assert_eq!(info.key, "A Minor"); + assert_eq!(info.bpm_confidence, Some(3.9)); + assert_eq!(info.key_strength, Some(0.81)); } #[test] + fn format_confidence_bands_and_omits_when_absent() { + assert_eq!(format_confidence(None, None), ""); + assert_eq!(format_confidence(None, Some(0.9)), ""); + assert_eq!(format_confidence(Some(0.4), None), " (tempo 0.40 weak)"); + assert_eq!( + format_confidence(Some(2.0), Some(0.5)), + " (tempo 2.00 fair, key 0.50)" + ); + assert_eq!( + format_confidence(Some(4.2), Some(0.77)), + " (tempo 4.20 strong, key 0.77)" + ); + } + + #[test] fn file_metadata_defaults_to_none() { let md = FileMetadata::default(); assert!(md.artist.is_none()); blob - b849ec9c8639f2129b96cb6f604a7efb1faf2461 blob + e3ea471ba0d52eb20175c3a0812ea2536b470c22 --- src/import.rs +++ src/import.rs @@ -66,6 +66,9 @@ pub fn import_mixxx(db: &LibraryDb, mixxx_path: &str) .as_secs() .cast_signed(), ), + // Mixxx stores no analysis confidence. + bpm_confidence: None, + key_strength: None, }; entries.push((primary_key, report)); blob - fec00169505d4587a5086a3162857ce9835469ae blob + ce43adaaa23f979c411d0c1d2c81e2bbb37f9f26 --- src/main.rs +++ src/main.rs @@ -316,6 +316,8 @@ fn real_main() -> Result<()> { .as_secs() .cast_signed(), ), + bpm_confidence: track.bpm_confidence, + key_strength: track.key_strength, }; (key, report) }) blob - 559df748edf86a1561aa2df0a2f074f3f3e45cee blob + 34fa20a7a10d18be906373da3d212be4c16cd51c --- src/types.rs +++ src/types.rs @@ -7,6 +7,11 @@ pub struct TrackInfo { pub track: Option, pub bpm: f32, pub key: String, + /// Essentia's tempo confidence, `None` when the values came from existing + /// file tags rather than a fresh analysis. + pub bpm_confidence: Option, + /// Essentia's key correlation strength, `None` for the same reason. + pub key_strength: Option, } #[derive(Debug)] blob - /dev/null blob + 9f3f01db74fa8a2f813650dea7f2067e2c6937a6 (mode 644) --- /dev/null +++ void-build-essentia.rb @@ -0,0 +1,212 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "fileutils" +require "open3" +require "etc" + +class Builder + REPO = "https://repo-default.voidlinux.org/current" + DEPS = %w[ + cmake + make + gcc + qt5-devel + qt5-svg-devel + qt5-multimedia-devel + qt5-declarative-devel + qt5-webkit-devel + hunspell-devel + gstreamer1-devel + libxml2-devel + qt5-tools + ] + .freeze + + def initialize(argv) + @outdir = nil + @chroot = "/var/chroot/otter-build" + @keep = false + @quiet = false + parse(argv) + validate + end + + def run + bootstrap + mount_vfs + prep_chroot + install_deps + build + extract + ensure + unmount_vfs + remove_chroot unless @keep + msg("done.") + end + + private + + def parse(argv) + i = 0 + while i < argv.length + case argv[i] + when "-o" + @outdir = argv[i += 1] + when "-c" + @chroot = argv[i += 1] + when "-k" + @keep = true + when "-q" + @quiet = true + when "-h", "--help" + usage(code: 0) + else + usage + end + + i += 1 + end + end + + def usage_lines + [ + "Usage: #{File.basename($0)} [-o DIR] [-c DIR] [-k] [-q]", + " -o DIR output directory (default: invoking user home)", + " -c DIR chroot directory (default: /var/chroot/otter-build)", + " -k keep chroot after build", + " -q quiet" + ] + end + + def usage(code: 1) + usage_lines.each { |l| puts(l) } + exit(code) + end + + def validate + unless Process.uid.zero? + usage_lines.each { |l| puts(l) } + die("must run as root") + end + + @src = Dir.pwd + die("no CMakeLists.txt in #{@src}") unless File.file?(File.join(@src, "CMakeLists.txt")) + + inv = ENV["SUDO_USER"] || ENV["DOAS_USER"] + if inv + @pw = Etc.getpwnam(inv) rescue nil + die("cannot resolve user #{inv}") unless @pw + @outdir ||= @pw.dir + else + @outdir ||= File.expand_path("~") + end + end + + def msg(s) = puts(s) unless @quiet + + def die(s) + $stderr.puts("#{File.basename($0)}: #{s}") + exit(1) + end + + def run_cmd(cmd, fatal: true) + msg(cmd) + _o, e, st = Open3.capture3(cmd) + die("#{cmd}: #{e.strip}") if !st.success? && fatal + st.success? + end + + def chroot_cmd(script) + msg("chroot #{@chroot} /bin/bash -c '#{script}'") + die("chroot command failed") unless system("chroot #{@chroot} /bin/bash -c '#{script}'") + end + + def bootstrap + FileUtils.mkdir_p(@chroot) + return if File.executable?(File.join(@chroot, "bin/bash")) + + msg("bootstrapping chroot...") + kdir = File.join(@chroot, "var/db/xbps/keys") + FileUtils.mkdir_p(kdir) + Dir.glob("/var/db/xbps/keys/*").each { |f| FileUtils.cp(f, kdir) } + + run_cmd("xbps-install -Syu -R #{REPO} -r #{@chroot} base-voidstrap", fatal: false) + die("bootstrap failed") unless File.executable?(File.join(@chroot, "bin/bash")) + msg("bootstrap ok") + end + + def mount_vfs + mounts = { + File.join(@chroot, "proc") => ["-t", "proc", "proc"], + File.join(@chroot, "sys") => ["-t", "sysfs", "sys"], + File.join(@chroot, "dev") => ["--bind", "/dev"] + } + mounts.each do |dst, opts| + FileUtils.mkdir_p(dst) + next if system("mountpoint -q #{dst} 2>/dev/null") + system("mount #{opts.join(" ")} #{dst}") + end + end + + def unmount_vfs + %w[dev sys proc].each { |n| system("umount #{File.join(@chroot, n)} 2>/dev/null") } + end + + def prep_chroot + FileUtils.cp("/etc/resolv.conf", File.join(@chroot, "etc/resolv.conf")) + kdir = File.join(@chroot, "var/db/xbps/keys") + FileUtils.mkdir_p(kdir) + Dir.glob("/var/db/xbps/keys/*").each { |f| FileUtils.cp(f, kdir) } + end + + def install_deps + chroot_cmd("xbps-install -Syu") + chroot_cmd("xbps-install -S #{DEPS.join(" ")}") + end + + def build + dst = File.join(@chroot, "src/otter-browser") + FileUtils.rm_rf(dst) + FileUtils.mkdir_p(File.dirname(dst)) + system("cp -a #{@src} #{dst}") || die("copy failed") + + cm = File.join(dst, "CMakeLists.txt") + txt = File.read(cm) + unless txt.include?("FATAL_ON_MISSING_REQUIRED_PACKAGES") + die("CMakeLists.txt: FATAL_ON_MISSING_REQUIRED_PACKAGES not found") + end + + txt.gsub!("FATAL_ON_MISSING_REQUIRED_PACKAGES", "") + File.write(cm, txt) + + script = "set -e; cd /src/otter-browser; rm -rf build; mkdir build; cd build; cmake -DENABLE_QT5=ON ../; make -j$(nproc)" + chroot_cmd(script) + end + + def extract + bin = File.join(@chroot, "src/otter-browser/build/otter-browser") + die("binary not found") unless File.file?(bin) + + out = File.join(@outdir, "otter-browser") + system("cp #{bin} #{out}") || die("copy binary failed") + FileUtils.chmod("+x", out) + + if @pw + begin + File.chown(@pw.uid, @pw.gid, out) + rescue => e + msg("chown failed: #{e}") + end + end + + msg("binary: #{out}") + end + + def remove_chroot + msg("removing #{@chroot}...") + FileUtils.rm_rf(@chroot) + end +end + +Builder.new(ARGV).run