commit d99a4c034839f9f25bdaab6af68cd0f56a967152 from: mtmn date: Mon Jul 27 20:34:38 2026 UTC remove multi-thread patch 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 ` | Thread count (0 = all cores) | +| `-j`, `--jobs ` | Files analyzed at once, each in its own process (0 = all cores) | | `-l`, `--list` | List reports in the database | | `--limit ` | Cap listed entries (default 1000, 0 = all) | | `--query ` | 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::addVisibleDependencies(mapprintBufferFillState(); -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 = 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 { .to_str() .with_context(|| format!("file path is not valid UTF-8: {}", path.display()))?; - let mut loader = { - let _guard = lock_factory(); - ESSENTIA - .create::() - .filename(path_str) - .sample_rate(SAMPLE_RATE) - .configure() - .context("failed to configure Essentia MonoLoader")? - }; + let mut loader = ESSENTIA + .create::() + .filename(path_str) + .sample_rate(SAMPLE_RATE) + .configure() + .context("failed to configure Essentia MonoLoader")?; let audio: Vec = loader .compute() .with_context(|| format!("Essentia failed to decode {}", path.display()))? .audio() .get(); - let mut rhythm = { - let _guard = lock_factory(); - ESSENTIA - .create::() - .method(RhythmExtractor2013Method::Multifeature) - .configure() - .context("failed to configure Essentia RhythmExtractor2013")? - }; + let mut rhythm = ESSENTIA + .create::() + .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::() - .profile_type(KeyExtractorProfileType::Edma) - .configure() - .context("failed to configure Essentia KeyExtractor")? - }; + let mut key = ESSENTIA + .create::() + .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 { }) } -/// 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 { + 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 { + 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 { + field + .parse::() + .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, } +/// 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 { +pub fn process_file(path: &Path, options: ProcessOptions) -> Result { 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::(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::>(); + 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, })