Commit Diff


commit - 44a3e5ae896291f862fabf74b61b184c6a774584
commit + d99a4c034839f9f25bdaab6af68cd0f56a967152
blob - 12e7345d55f8e7c284aaa325fb98522a031cc2e2
blob + 26b5ae38f83806f1db264ca3b268ff8626f1e967
--- README.md
+++ README.md
@@ -23,8 +23,8 @@ must resolve:
 Essentia is rarely packaged as a library, so it generally has to be built from
 source. Build it from a recent `master`, not the `v2.1_beta5` tag: beta5 needs
 `libavresample`, removed in ffmpeg 5, while essentia-rs probes for
-`libswresample`. Apply `nix/essentia-network-thread-local.patch` — without it,
-analysis on more than one thread aborts with a double free.
+`libswresample`. A stock build is fine — `-j` analyzes one file per worker
+process precisely so that Essentia's process-global state is never shared.
 
 A C++17 compiler is required; the bindings are generated and compiled with
 `cxx`. essentia-rs links TensorFlow unless `USE_TENSORFLOW=0`, which
@@ -69,7 +69,7 @@ virittaa [OPTIONS] [PATH]...
 | `-t`, `--write-tags` | Write detected BPM and key to file metadata |
 | `--no-store` | Do not save reports to the database |
 | `-f`, `--force` | Re-analyze files that already carry BPM/key tags |
-| `-j`, `--jobs <N>` | Thread count (0 = all cores) |
+| `-j`, `--jobs <N>` | Files analyzed at once, each in its own process (0 = all cores) |
 | `-l`, `--list` | List reports in the database |
 | `--limit <N>` | Cap listed entries (default 1000, 0 = all) |
 | `--query <KEY>` | Look up one track by exact key |
blob - dd0ca873a6aea4567ea5550797bacaeab260efbf
blob + dd626ae9e583100ac7eb428504bfaf608c7072e5
--- hack/void-build.rb
+++ hack/void-build.rb
@@ -6,7 +6,7 @@
 #
 # Essentia is not in the Void repos, and virittaa needs it as a library (headers,
 # libessentia.so, essentia.pc) rather than the prebuilt extractor binary. Same
-# commit and patch as nix/essentia.nix, so both builds line up.
+# commit as nix/essentia.nix, so both builds line up.
 #
 # Output is a relocatable bundle: the binary plus its private libraries under an
 # $ORIGIN rpath, runnable from anywhere without LD_LIBRARY_PATH. Fully static is
@@ -23,7 +23,6 @@ class Build
 
   # Kept in sync with nix/essentia.nix.
   ESSENTIA_REV = "b9fa6cb674ca43dfb94d28d293aeda441c6745db"
-  ESSENTIA_PATCH = "nix/essentia-network-thread-local.patch"
 
   # The git dependency in Cargo.toml; cloned unless -e gives a local checkout.
   ESSENTIA_RS_URL = "ssh://anonymous@mtmn.name/essentia-rs.git"
@@ -33,7 +32,7 @@ class Build
   # eigen is headers-only (no -devel package). zlib-devel is needed because
   # taglib's pkg-config Libs pulls in -lz.
   DEPS = %w[
-    base-devel patch python3 pkg-config
+    base-devel python3 pkg-config
     eigen fftw-devel ffmpeg6-devel libsamplerate-devel taglib-devel
     chromaprint-devel libyaml-devel zlib-devel
     rust cargo git clang sqlite-devel patchelf
@@ -112,8 +111,6 @@ class Build
     die("must run as root") unless Process.uid.zero?
 
     @source = File.expand_path(@source)
-    @patch = File.join(@source, ESSENTIA_PATCH)
-    die("#{ESSENTIA_PATCH} not found under #{@source}") unless File.file?(@patch)
 
     unless @essentia_only
       die("no Cargo.toml in #{@source}") unless File.file?(File.join(@source, "Cargo.toml"))
@@ -261,18 +258,11 @@ class Build
 
   def build_essentia
     stage_essentia
-    FileUtils.mkdir_p(File.join(@chroot, "tmp"))
-    FileUtils.cp(@patch, File.join(@chroot, "tmp/essentia.patch"))
     FileUtils.rm_rf(@staging)
 
     chroot_sh("building essentia (this takes a while)", <<~SH)
       export HOME=/tmp
       cd /src/essentia
-      if patch -p1 -R -f -s --dry-run -i /tmp/essentia.patch >/dev/null 2>&1; then
-        echo "essentia: already patched"
-      else
-        patch -p1 -i /tmp/essentia.patch
-      fi
       python3 waf configure --prefix=/usr
       python3 waf -j$(nproc)
       python3 waf install --destdir=/staging
blob - 159f900beb9542b2ad02a131bf7dc7a39be269c1 (mode 644)
blob + /dev/null
--- nix/essentia-network-thread-local.patch
+++ /dev/null
@@ -1,51 +0,0 @@
-Make scheduler::Network::lastCreated thread-local.
-
-lastCreated is a process-global pointer, written by every Network constructor
-and destructor and read by every Network::run() via
-printNetworkBufferFillState(). MonoLoader, RhythmExtractor2013 and KeyExtractor
-each build an inner Network, so analysing files on more than one thread makes
-threads clobber each other's pointer: warning spam, interleaved output, then a
-use-after-free ("double free detected in tcache"). It is only a debug
-convenience, so make it per-thread; Essentia otherwise supports
-one-instance-per-thread use (FFTW plan creation and the devnull/copy singletons
-are ForcedMutex-guarded).
-
-The second hunk fixes the null dereference on the line right after the warning
-that says the pointer is null.
-
-Not yet submitted upstream.
-
-diff --git a/src/essentia/scheduler/network.cpp b/src/essentia/scheduler/network.cpp
-index 7046731d..64bf94fc 100644
---- a/src/essentia/scheduler/network.cpp
-+++ b/src/essentia/scheduler/network.cpp
-@@ -164,7 +164,7 @@ vector<NetworkNode*> NetworkNode::addVisibleDependencies(map<Algorithm*, Network
- }
- 
- 
--Network* Network::lastCreated = 0;
-+thread_local Network* Network::lastCreated = 0;
- 
- Network::Network(Algorithm* generator, bool takeOwnership) : _takeOwnership(takeOwnership),
-                                                              _generator(generator),
-@@ -969,6 +969,7 @@ void Network::printBufferFillState() {
- void printNetworkBufferFillState() {
-   if (!Network::lastCreated) {
-     E_WARNING("No network created, or last created network has been deleted...");
-+    return;
-   }
- 
-   Network::lastCreated->printBufferFillState();
-diff --git a/src/essentia/scheduler/network.h b/src/essentia/scheduler/network.h
-index c8712b2c..acc5e0d2 100644
---- a/src/essentia/scheduler/network.h
-+++ b/src/essentia/scheduler/network.h
-@@ -200,7 +200,7 @@ class Network {
-    * Last instance of Network created, 0 if it has been deleted or if
-    * no network has been created yet.
-    */
--  static Network* lastCreated;
-+  static thread_local Network* lastCreated;
- 
-  protected:
-   bool _takeOwnership;
blob - 3d8c2637a8bc83962ddd3f516f95cb0f1e218415
blob + c4cdead18f5c239a248fb379ca7a07b42a09827e
--- nix/essentia.nix
+++ nix/essentia.nix
@@ -27,10 +27,6 @@ stdenv.mkDerivation {
     hash = "sha256-8z7SZN6tO/3iB2bOzPnz1en0NjbgGJcTjkj02PzvdVs=";
   };
 
-  # Required for multi-threaded analysis; see the patch header.
-  # hack/void-build.rb applies the same one.
-  patches = [./essentia-network-thread-local.patch];
-
   strictDeps = true;
 
   nativeBuildInputs = [
blob - 205dcc00c13a83ab58adb38155b1270b8092e263
blob + 3950f683f9d47dbfa23f5facb38b79ad33f2c251
--- src/audio_processing.rs
+++ src/audio_processing.rs
@@ -4,11 +4,19 @@
 //! `MonoLoader` decodes and downmixes to mono at 44.1 kHz, `RhythmExtractor2013`
 //! (multifeature) estimates tempo, `KeyExtractor` (EDMA) estimates key. The BPM
 //! is Essentia's estimate: no offset, no octave-folding into a range.
+//!
+//! Essentia keeps process-global state -- an `AlgorithmFactory` that is not safe
+//! for concurrent `create`, and a `scheduler::Network::lastCreated` pointer that
+//! every inner network writes and every `run()` reads -- so two analyses must
+//! never overlap in one process. Parallelism is therefore process-level:
+//! [`analyze_in_worker`] hands each file to a short-lived child, which is what
+//! makes `-j` safe against a stock libessentia.
 
 use std::path::Path;
-use std::sync::{LazyLock, Mutex};
+use std::process::{Command, Stdio};
+use std::sync::LazyLock;
 
-use anyhow::{Context, Result};
+use anyhow::{Context, Result, bail};
 use essentia::algorithm::input_output::MonoLoader;
 use essentia::algorithm::rhythm::{RhythmExtractor2013, RhythmExtractor2013Method};
 use essentia::algorithm::tonal::{KeyExtractor, KeyExtractorProfileType};
@@ -17,20 +25,13 @@ use essentia::{Essentia, GetFromDataContainer};
 /// Sample rate Essentia's extractors expect; also what `MonoLoader` resamples to.
 const SAMPLE_RATE: f32 = 44100.0;
 
+/// Hidden CLI argument that puts a process into worker mode; see [`run_worker`].
+pub const WORKER_ARG: &str = "analyze-one";
+
 /// `Essentia::new()` reference-counts a global lifecycle, so one live handle
 /// keeps the library initialized for the process.
 static ESSENTIA: LazyLock<Essentia> = LazyLock::new(Essentia::new);
 
-/// `AlgorithmFactory` is not safe for concurrent `create`, and the composite
-/// extractors instantiate inner algorithms through it while configuring, so the
-/// create+configure phase is serialized; `compute` runs unlocked.
-///
-/// Unlocked `compute` requires an Essentia whose `scheduler::Network::lastCreated`
-/// is `thread_local` (`nix/essentia-network-thread-local.patch`); upstream it is
-/// a process-global that concurrent analysis corrupts into a double free.
-/// Against an unpatched build, hold this lock across the whole function.
-static FACTORY_LOCK: Mutex<()> = Mutex::new(());
-
 /// Result of analysing a single track.
 #[derive(Debug, Clone)]
 pub struct Analysis {
@@ -46,6 +47,9 @@ pub struct Analysis {
 
 /// Analyze a single audio file, detecting its BPM and key.
 ///
+/// Drives Essentia in this process, so it must not run concurrently with another
+/// analysis here; see the module docs and [`analyze_in_worker`].
+///
 /// # Errors
 ///
 /// Returns an error if the path is not valid UTF-8 or if Essentia fails to
@@ -55,43 +59,34 @@ pub fn analyze_file(path: &Path) -> Result<Analysis> {
         .to_str()
         .with_context(|| format!("file path is not valid UTF-8: {}", path.display()))?;
 
-    let mut loader = {
-        let _guard = lock_factory();
-        ESSENTIA
-            .create::<MonoLoader>()
-            .filename(path_str)
-            .sample_rate(SAMPLE_RATE)
-            .configure()
-            .context("failed to configure Essentia MonoLoader")?
-    };
+    let mut loader = ESSENTIA
+        .create::<MonoLoader>()
+        .filename(path_str)
+        .sample_rate(SAMPLE_RATE)
+        .configure()
+        .context("failed to configure Essentia MonoLoader")?;
     let audio: Vec<f32> = loader
         .compute()
         .with_context(|| format!("Essentia failed to decode {}", path.display()))?
         .audio()
         .get();
 
-    let mut rhythm = {
-        let _guard = lock_factory();
-        ESSENTIA
-            .create::<RhythmExtractor2013>()
-            .method(RhythmExtractor2013Method::Multifeature)
-            .configure()
-            .context("failed to configure Essentia RhythmExtractor2013")?
-    };
+    let mut rhythm = ESSENTIA
+        .create::<RhythmExtractor2013>()
+        .method(RhythmExtractor2013Method::Multifeature)
+        .configure()
+        .context("failed to configure Essentia RhythmExtractor2013")?;
     let rhythm_result = rhythm
         .compute(audio.as_slice())
         .context("Essentia tempo analysis failed")?;
     let bpm = rhythm_result.bpm().get();
     let bpm_confidence = rhythm_result.confidence().get();
 
-    let mut key = {
-        let _guard = lock_factory();
-        ESSENTIA
-            .create::<KeyExtractor>()
-            .profile_type(KeyExtractorProfileType::Edma)
-            .configure()
-            .context("failed to configure Essentia KeyExtractor")?
-    };
+    let mut key = ESSENTIA
+        .create::<KeyExtractor>()
+        .profile_type(KeyExtractorProfileType::Edma)
+        .configure()
+        .context("failed to configure Essentia KeyExtractor")?;
     let key_result = key
         .compute(audio.as_slice())
         .context("Essentia key analysis failed")?;
@@ -107,12 +102,75 @@ pub fn analyze_file(path: &Path) -> Result<Analysis> {
     })
 }
 
-/// Recovers from poisoning: a panic in one analysis thread must not wedge every
-/// remaining file.
-fn lock_factory() -> std::sync::MutexGuard<'static, ()> {
-    FACTORY_LOCK.lock().unwrap_or_else(|e| e.into_inner())
+/// Analyze `path` in a short-lived child process, so several files can be in
+/// flight at once without sharing Essentia's process-global state.
+///
+/// `Command` forks and execs, which is the only safe thing to do from a threaded
+/// parent; a bare `fork` would inherit locks held by other threads. The child's
+/// stderr is inherited, so Essentia's own diagnostics reach the terminal.
+///
+/// # Errors
+///
+/// Returns an error if this binary cannot be located or spawned, if the worker
+/// exits non-zero, or if its output cannot be parsed.
+pub fn analyze_in_worker(path: &Path) -> Result<Analysis> {
+    let exe = std::env::current_exe().context("cannot locate the virittaa binary")?;
+    let output = Command::new(&exe)
+        .arg(format!("--{WORKER_ARG}"))
+        .arg(path)
+        .stderr(Stdio::inherit())
+        .output()
+        .with_context(|| format!("failed to run {}", exe.display()))?;
+
+    if !output.status.success() {
+        bail!("analysis worker {}", output.status);
+    }
+
+    let line = String::from_utf8(output.stdout).context("worker output is not valid UTF-8")?;
+    decode_analysis(&line)
 }
 
+/// Worker entry point: analyze one file and write the result to stdout.
+///
+/// # Errors
+///
+/// Propagates whatever [`analyze_file`] failed with; the parent surfaces it
+/// through the worker's exit status and inherited stderr.
+pub fn run_worker(path: &Path) -> Result<()> {
+    println!("{}", encode_analysis(&analyze_file(path)?));
+    Ok(())
+}
+
+/// Worker wire format: one tab-separated line, key last because it is the only
+/// field that can contain spaces.
+fn encode_analysis(analysis: &Analysis) -> String {
+    format!(
+        "{}\t{}\t{}\t{}",
+        analysis.bpm, analysis.bpm_confidence, analysis.key_strength, analysis.key
+    )
+}
+
+fn decode_analysis(line: &str) -> Result<Analysis> {
+    let line = line.trim_end_matches(['\r', '\n']);
+    let fields: Vec<&str> = line.splitn(4, '\t').collect();
+    let [bpm, bpm_confidence, key_strength, key] = fields[..] else {
+        bail!("malformed worker output: {line:?}");
+    };
+
+    let number = |field: &str, what: &str| -> Result<f32> {
+        field
+            .parse::<f32>()
+            .with_context(|| format!("worker reported an unparseable {what}: {field:?}"))
+    };
+
+    Ok(Analysis {
+        bpm: number(bpm, "bpm")?,
+        key: key.to_string(),
+        bpm_confidence: number(bpm_confidence, "tempo confidence")?,
+        key_strength: number(key_strength, "key strength")?,
+    })
+}
+
 /// Combine Essentia's note (`"C"`, `"F#"`) and scale (`"major"`, `"minor"`)
 /// into virittaa's display form. Unexpected scale values pass through verbatim.
 fn format_key(note: &str, scale: &str) -> String {
@@ -139,4 +197,33 @@ mod tests {
     fn format_key_passes_through_unexpected_scale() {
         assert_eq!(format_key("C", "dorian"), "C dorian");
     }
+
+    #[test]
+    fn analysis_survives_a_worker_round_trip() {
+        let analysis = Analysis {
+            bpm: 128.5,
+            key: "F# Minor".to_string(),
+            bpm_confidence: 3.871,
+            key_strength: 0.79,
+        };
+        let back = decode_analysis(&encode_analysis(&analysis)).unwrap();
+
+        assert!((back.bpm - analysis.bpm).abs() < f32::EPSILON);
+        assert!((back.bpm_confidence - analysis.bpm_confidence).abs() < f32::EPSILON);
+        assert!((back.key_strength - analysis.key_strength).abs() < f32::EPSILON);
+        assert_eq!(back.key, analysis.key);
+    }
+
+    #[test]
+    fn decode_analysis_rejects_truncated_and_unparseable_output() {
+        assert!(decode_analysis("").is_err());
+        assert!(decode_analysis("128.5\t3.87\t0.79").is_err());
+        assert!(decode_analysis("nope\t3.87\t0.79\tA Minor").is_err());
+    }
+
+    #[test]
+    fn decode_analysis_tolerates_the_trailing_newline() {
+        let back = decode_analysis("128.5\t3.87\t0.79\tA Minor\n").unwrap();
+        assert_eq!(back.key, "A Minor");
+    }
 }
blob - cc8195f28191a8df91f2dfdac9b3acded7ba6c97
blob + b7561f41ce77e6d5135cc8bc7bba22c53990b1ad
--- src/file_io.rs
+++ src/file_io.rs
@@ -9,7 +9,7 @@ const IFF_FORM_SIZE: usize = 4;
 const IFF_HEADER_LEN: usize = 8;
 const IFF_CONTAINER_HEADER: usize = 12;
 
-use crate::audio_processing::analyze_file;
+use crate::audio_processing::{analyze_file, analyze_in_worker};
 use crate::types::TrackInfo;
 
 #[derive(Debug, Default)]
@@ -18,6 +18,18 @@ pub struct FileMetadata {
     pub track: Option<String>,
 }
 
+/// How [`process_file`] should treat one file.
+#[derive(Debug, Clone, Copy)]
+pub struct ProcessOptions {
+    /// Write the detected BPM and key back to the file's tags.
+    pub write_tags: bool,
+    /// Re-analyze even when the file already carries both tags.
+    pub force: bool,
+    /// Run the analysis in a child process. Required whenever more than one file
+    /// can be analyzed at a time, since Essentia's state is process-global.
+    pub isolate: bool,
+}
+
 /// `RhythmExtractor2013`'s multifeature method reports roughly 0..5.3; Essentia
 /// treats below 1.5 as unreliable and above 3.5 as strong. Octave errors and
 /// ambiguous material land in the low band.
@@ -37,10 +49,12 @@ fn format_confidence(bpm_confidence: f32, key_strength
 /// # Errors
 ///
 /// Returns an error string if the file cannot be decoded or key detection fails.
-pub fn process_file(path: &Path, write_tags: bool, force: bool) -> Result<TrackInfo, String> {
+pub fn process_file(path: &Path, options: ProcessOptions) -> Result<TrackInfo, String> {
     let (metadata, existing_tags) = read_tags(path);
 
-    if !force && let Some((bpm, key)) = existing_tags {
+    if !options.force
+        && let Some((bpm, key)) = existing_tags
+    {
         println!(
             "File: {}, BPM: {bpm:.1}, Key: {key} (existing tags)",
             path.display()
@@ -56,7 +70,12 @@ pub fn process_file(path: &Path, write_tags: bool, for
         });
     }
 
-    let analysis = analyze_file(path).map_err(|e| e.to_string())?;
+    let analysis = if options.isolate {
+        analyze_in_worker(path)
+    } else {
+        analyze_file(path)
+    }
+    .map_err(|e| e.to_string())?;
     let bpm = analysis.bpm;
     let key = analysis.key;
 
@@ -66,7 +85,9 @@ pub fn process_file(path: &Path, write_tags: bool, for
         format_confidence(analysis.bpm_confidence, analysis.key_strength)
     );
 
-    if write_tags && let Err(e) = write_metadata(path, bpm, &key) {
+    if options.write_tags
+        && let Err(e) = write_metadata(path, bpm, &key)
+    {
         eprintln!("Error writing metadata for {}: {e}", path.display());
     }
 
blob - ce43adaaa23f979c411d0c1d2c81e2bbb37f9f26
blob + e01f9c2160167e26dda96712c8b62b24dda1849b
--- src/main.rs
+++ src/main.rs
@@ -13,9 +13,10 @@ use regex::Regex;
 use std::path::PathBuf;
 use walkdir::WalkDir;
 
+use crate::audio_processing::{WORKER_ARG, run_worker};
 use crate::db::LibraryDb;
 use crate::db::TrackReport;
-use crate::file_io::process_file;
+use crate::file_io::{ProcessOptions, process_file};
 use crate::import::import_mixxx;
 use crate::types::TrackError;
 use crate::utils::{format_primary_key, is_supported_audio_file, print_entries};
@@ -53,7 +54,7 @@ fn build_cli() -> Command {
             Arg::new("jobs")
                 .short('j')
                 .long("jobs")
-                .help("Number of threads (0 = all cores)")
+                .help("Files to analyze at once (0 = all cores)")
                 .default_value("0")
                 .value_parser(clap::value_parser!(usize)),
         )
@@ -109,6 +110,12 @@ fn build_cli() -> Command {
                 .long("db-path")
                 .help("Database directory (default: ~/.local/share/virittaa)"),
         )
+        .arg(
+            Arg::new(WORKER_ARG)
+                .long(WORKER_ARG)
+                .hide(true)
+                .help("Analyze one file and print the result; spawned by --jobs"),
+        )
 }
 
 #[allow(clippy::too_many_lines)]
@@ -133,6 +140,10 @@ fn real_main() -> Result<()> {
 
     let matches = cmd.get_matches();
 
+    if let Some(path) = matches.get_one::<String>(WORKER_ARG) {
+        return run_worker(std::path::Path::new(path));
+    }
+
     let write_tags = matches.get_flag("write-tags");
     let no_store = matches.get_flag("no-store");
     let force = matches.get_flag("force");
@@ -280,10 +291,19 @@ fn real_main() -> Result<()> {
         })
         .collect::<Vec<_>>();
 
+    let options = ProcessOptions {
+        write_tags,
+        force,
+        // Essentia cannot drive two analyses at once in one process, so anything
+        // that can overlap goes through a worker. A strictly serial run does not
+        // have to pay for the spawn.
+        isolate: jobs != 1 && files_to_process.len() > 1,
+    };
+
     let (results, errors): (Vec<_>, Vec<_>) = files_to_process
         .par_iter()
         .map(|path| {
-            process_file(path, write_tags, force).map_err(|reason| TrackError {
+            process_file(path, options).map_err(|reason| TrackError {
                 path: path.clone(),
                 reason,
             })