Commit Diff


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<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
@@ -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<Ve
     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 {
@@ -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<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
@@ -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<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
@@ -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