commit - 44a3e5ae896291f862fabf74b61b184c6a774584
commit + d99a4c034839f9f25bdaab6af68cd0f56a967152
blob - 12e7345d55f8e7c284aaa325fb98522a031cc2e2
blob + 26b5ae38f83806f1db264ca3b268ff8626f1e967
--- README.md
+++ README.md
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
| `-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
#
# 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
# 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"
# 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
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"))
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
-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
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
//! `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};
/// 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 {
/// 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
.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")?;
})
}
-/// 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 {
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
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)]
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.
/// # 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()
});
}
- 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;
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
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};
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)),
)
.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)]
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");
})
.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,
})