commit - 0cc756c5727a93adc29fedbcab77cfefea7724fa
commit + b2fd8f182d36836b3041cc710a6545e88e0f4bd4
blob - 4075fb24e83adb9df763f0eaa25b60c73b1c0a37
blob + 9ea5b7226a083bcda854477e9cd265361ff484fb
--- .gitignore
+++ .gitignore
target
result
+zilinaplayed
+essentia
blob - 97df38831b5402d041b70c32b12fc7c768090ced
blob + f63663e8f03aabdbc0f8102cb7a463969528e342
--- src/audio_processing.rs
+++ src/audio_processing.rs
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
pub key: Option<String>,
#[serde(default)]
pub timestamp: Option<i64>,
+ /// 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<f32>,
+ /// Essentia's key correlation strength. `None` for the same reasons.
+ #[serde(default)]
+ pub key_strength: Option<f32>,
}
+/// `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<String>,
+ track: Option<String>,
+ bpm: Option<f64>,
+ key: Option<String>,
+ timestamp: Option<i64>,
+}
+
+impl From<LegacyTrackReport> 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
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(())
}
}
postcard::to_allocvec(report).context("Failed to serialize track report")
}
+/// Decode a stored report, tolerating records written before the confidence
+/// fields were added (see `LegacyTrackReport`). Such records are upgraded in
+/// place the next time the track is saved.
fn deserialize_report(bytes: &[u8]) -> Result<TrackReport> {
- postcard::from_bytes(bytes).context("Failed to deserialize track report")
+ match postcard::from_bytes::<TrackReport>(bytes) {
+ Ok(report) => Ok(report),
+ Err(current_err) => postcard::from_bytes::<LegacyTrackReport>(bytes)
+ .map(TrackReport::from)
+ .map_err(|_| current_err)
+ .context("Failed to deserialize track report"),
+ }
}
fn effective_limit(limit: usize) -> usize {
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();
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()),
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 {
bpm: None,
key: None,
timestamp: None,
+ bpm_confidence: None,
+ key_strength: None,
};
assert_eq!(empty.to_string(), "Unknown - Unknown | BPM: - | Key: -");
}
bpm: Some(bpm),
key: Some(key.to_string()),
timestamp: Some(ts),
+ bpm_confidence: None,
+ key_strength: None,
},
)
}
bpm: Some(120.0),
key: Some("C Major".to_string()),
timestamp: Some(1),
+ bpm_confidence: None,
+ key_strength: None,
};
db.save(&key, &first).unwrap();
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
pub track: Option<String>,
}
-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<f32>,
+ key_strength: Option<f32>,
+) -> 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<f32>, key_strength: Option<f32>) -> 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
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)>) {
metadata,
120.5,
"A Minor".to_string(),
+ Some(3.9),
+ Some(0.81),
);
assert_eq!(info.path, Path::new("/music/xtal.flac"));
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
.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
.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
pub track: Option<String>,
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<f32>,
+ /// Essentia's key correlation strength, `None` for the same reason.
+ pub key_strength: Option<f32>,
}
#[derive(Debug)]
blob - /dev/null
blob + 9f3f01db74fa8a2f813650dea7f2067e2c6937a6 (mode 644)
--- /dev/null
+++ void-build-essentia.rb
+#!/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