commit 0cc756c5727a93adc29fedbcab77cfefea7724fa from: mtmn date: Mon Jul 27 13:02:36 2026 UTC track essentia master, make analysis thread-safe commit - 79b649367f789a4db56a62e6324a174d9c0b325b commit + 0cc756c5727a93adc29fedbcab77cfefea7724fa blob - 4d354047206c1350b95b254dfd6ba4c831cbad41 blob + a10869184935066d78d48b0212a866853c6b4b8f --- flake.nix +++ flake.nix @@ -42,11 +42,33 @@ craneLib = (crane.mkLib pkgs).overrideToolchain stableToolchain; # Essentia is not packaged as a library in nixpkgs (only the prebuilt - # extractor binary), so we build it from source. See nix/essentia.nix. - # essentia beta5 bundles waf 2.0.10, which imports the `imp` module - # removed in Python 3.12, so build it with Python 3.11. - essentia = pkgs.callPackage ./nix/essentia.nix {python3 = pkgs.python311;}; + # extractor binary), so we build it from source. See nix/essentia.nix + # for why it tracks master rather than the v2.1_beta5 tag. + essentia = pkgs.callPackage ./nix/essentia.nix {}; + # Every shared library virittaa ends up with a DT_NEEDED entry for. + # rustc emits `-L native=...` but no RPATH for any of them, so this list + # is needed twice: exported as LD_LIBRARY_PATH at build time (so + # essentia-codegen's build script, which links libessentia and is then + # *executed* to introspect it, can load), and again in the runtime + # wrapper. + runtimeLibs = + [ + essentia + pkgs.ffmpeg + pkgs.fftwFloat + pkgs.taglib + pkgs.libsamplerate + pkgs.chromaprint + pkgs.libyaml + pkgs.zlib + # rusqlite/libsqlite3-sys builds unbundled here, so it links the + # system libsqlite3 rather than compiling its own. + pkgs.sqlite + ] + # The cxx bridge drags in libstdc++.so.6. + ++ pkgs.lib.optionals (!pkgs.stdenv.isDarwin) [pkgs.stdenv.cc.cc.lib]; + # virittaa's Cargo.toml references the essentia-rs crate as a sibling path # dependency (`../essentia-rs/essentia`). That sibling does not exist in # the build sandbox, so assemble a source tree that contains both crates @@ -61,11 +83,23 @@ chmod -R u+w $out/nix-vendor-essentia-rs substituteInPlace $out/Cargo.toml \ --replace-fail '../essentia-rs/essentia' 'nix-vendor-essentia-rs/essentia' + + # essentia_sys's build.rs unconditionally appends "eigen3" to every + # include path eigen3.pc reports. That is right on Debian, whose + # eigen3.pc says `-I/usr/include`, but nixpkgs' already says + # `-I$out/include/eigen3` -- so the bridge gets `include/eigen3/eigen3` + # and cannot find . Only append when + # the reported path is not already the Eigen root. + # + # This belongs upstream in essentia-rs; when it lands there, + # --replace-fail will start failing and this block can go. + substituteInPlace $out/nix-vendor-essentia-rs/essentia_sys/build.rs \ + --replace-fail 'if library.name == "eigen3" {' \ + 'if library.name == "eigen3" && !include_path.join("Eigen").is_dir() {' ''; baseNativeBuildInputs = with pkgs; [ pkg-config - cmake llvmPackages.libclang ]; @@ -87,10 +121,20 @@ "-isystem ${pkgs.llvmPackages.libclang.lib}/lib/clang/${pkgs.llvmPackages.libclang.version}/include" + pkgs.lib.optionalString (!pkgs.stdenv.isDarwin) " -isystem ${pkgs.glibc.dev}/include"; - nativeBuildInputs = - baseNativeBuildInputs - ++ pkgs.lib.optionals (!pkgs.stdenv.isDarwin) [pkgs.glibc.dev]; + nativeBuildInputs = baseNativeBuildInputs; + # essentia-codegen's build script is linked against libessentia and + # then run by cargo to introspect the algorithm registry. rustc emits + # `-L native=...` but no RPATH, so the loader cannot find the .so: + # "error while loading shared libraries: libessentia.so". The upstream + # fix is for essentia_sys's build.rs to emit + # `cargo:rustc-link-arg=-Wl,-rpath,` for each probed library, + # which would also make the final binary self-contained and retire the + # wrapper below. + preBuild = '' + export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + ''; + buildInputs = [ # essentia-rs's essentia_sys probes each of these via pkg-config at @@ -100,14 +144,16 @@ # builds against, so the store paths match libessentia's. essentia pkgs.eigen - pkgs.ffmpeg_4 + pkgs.ffmpeg pkgs.fftwFloat - pkgs.taglib_1 + pkgs.taglib pkgs.libsamplerate pkgs.chromaprint pkgs.libyaml # taglib's pkg-config Libs pulls in -lz. pkgs.zlib + # libsqlite3-sys links the system sqlite (no `bundled` feature). + pkgs.sqlite ] ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [pkgs.libiconv]; }; @@ -121,15 +167,13 @@ cargoArtifacts = null; nativeBuildInputs = commonArgs.nativeBuildInputs ++ [pkgs.makeWrapper]; postInstall = '' - wrapProgram $out/bin/virittaa --prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath [ - essentia - pkgs.ffmpeg_4 - pkgs.fftwFloat - pkgs.taglib_1 - pkgs.libsamplerate - pkgs.chromaprint - pkgs.libyaml - ]}" + # The build-time failure above shows rustc does not add an RPATH + # for these, so the installed binary needs the same help. Verify + # with `patchelf --print-rpath result/bin/.virittaa-wrapped`; + # if the rpath ever covers them, drop this and makeWrapper -- + # LD_LIBRARY_PATH leaks into anything virittaa execs. + wrapProgram $out/bin/virittaa \ + --prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath runtimeLibs}" ''; }); in { @@ -138,8 +182,19 @@ inherit virittaa essentia; }; + # `nix flake check` only. Note that clippy gets no dependency cache + # (cargoArtifacts = null, see above), so it is a second full build of + # the tree -- expensive, but opt-in. A cargoTest check is deliberately + # absent: cleanCargoSource strips non-Rust files, so any test fixture + # would go missing. checks = { inherit virittaa; + fmt = craneLib.cargoFmt {inherit src;}; + clippy = craneLib.cargoClippy (commonArgs + // { + cargoArtifacts = null; + cargoClippyExtraArgs = "--all-targets -- --deny warnings"; + }); }; devShells.default = pkgs.mkShell { blob - 9d9cfa19999f876c078acbdcb97e1b2d1f0c9d8b blob + 1bcac2c2dfeb8d9ac17ab25daee37af9d6a4b6fd --- nix/BUILD_NOTES.md +++ nix/BUILD_NOTES.md @@ -24,7 +24,8 @@ was removed. `nix build` must build: them: `essentia eigen3 yaml-0.1 fftw3f taglib samplerate libchromaprint libavformat libswresample libavcodec libavutil` — plus **tensorflow unless `USE_TENSORFLOW=0`**. Note it links **swresample** (modern ffmpeg), not - avresample. + avresample; that is why essentia itself must be built from master, not from + the v2.1_beta5 tag (see below). ## Flake structure (flake.nix) @@ -39,49 +40,102 @@ was removed. `nix build` must build: Cargo.toml path `../essentia-rs/essentia` → `nix-vendor-essentia-rs/essentia`. essentia-rs is copied **raw** (not cleanCargoSource) because its C++ bridge files (`essentia_sys/bridge/*.cpp,*.h`) would be stripped by the cargo filter. +- The same step patches `essentia_sys/build.rs`: it appends `eigen3` to every + include path `eigen3.pc` reports, which is correct on Debian (`-I/usr/include`) + but wrong on nixpkgs (`-I$out/include/eigen3` already), yielding + `include/eigen3/eigen3` and `fatal error: unsupported/Eigen/CXX11/Tensor: No + such file or directory`. **This is an essentia-rs bug** — fix it there + (`if library.name == "eigen3" && !include_path.join("Eigen").is_dir()`), then + drop the patch here; `--replace-fail` will flag it once upstream changes. - `USE_TENSORFLOW = "0"` set in `commonArgs` (also in `.cargo/config.toml` for non-Nix builds). - Single-shot build: `cargoArtifacts = null` (no crane buildDepsOnly). The dep split is skipped because crane's dummy-source build would strip essentia-rs's - C++ files and can't run the codegen. + C++ files and can't run the codegen. Cost: every rebuild recompiles the whole + dep tree. - `buildInputs` lists the full pkg-config set explicitly (essentia, eigen, - ffmpeg_4, fftwFloat, taglib_1, libsamplerate, chromaprint, libyaml) so the + ffmpeg, fftwFloat, taglib, libsamplerate, chromaprint, libyaml, zlib) so the essentia_sys probes always resolve, rather than relying on essentia's propagatedBuildInputs (multi-output `.dev` propagation is unreliable). - `essentia` is exposed as a package output: `nix build .#essentia`. +- No `cmake` in nativeBuildInputs — nothing in the lockfile needs it (zstd-sys + and libsqlite3-sys both use `cc`). No `glibc.dev` either; the only thing that + wanted it was bindgen, which gets it via `BINDGEN_EXTRA_CLANG_ARGS`. +- `checks` adds `fmt` and `clippy`. `clippy` is a second uncached full build, + so it only runs under `nix flake check`, never `nix build`. ## nix/essentia.nix -- essentia `v2.1_beta5`, driven by its bundled `waf`. -- **Must build with Python 3.11**: waf 2.0.10 does `import imp`, removed in - Python 3.12 (nixpkgs-unstable default). flake.nix calls it with - `{ python3 = pkgs.python311; }`. Symptom if this regresses: - `ModuleNotFoundError: No module named 'imp'` during configure. -- propagatedBuildInputs: fftwFloat ffmpeg_4 libsamplerate taglib_1 chromaprint - libyaml. (ffmpeg_4 also provides libswresample, so it's consistent with what - essentia_sys links.) -- installPhase patches essentia.pc Cflags to add `-I${includedir}`. +- essentia is pinned to a **master commit** (`b9fa6cb`, 2.1-beta6-dev, + 2026-05-20), not the `v2.1_beta5` tag. beta5 is a dead end: it needs + `libavresample` (removed in ffmpeg 5, so it forces `ffmpeg_4`) and bundles + waf 2.0.10, which needs Python ≤ 3.11 (`import imp`) *and* source patching + for the `'rU'` open mode Python 3.11 removed. Master uses `libswresample` + and bundles waf 2.0.25, which runs unmodified on Python 3.14. +- Consequently: plain `ffmpeg`, plain `taglib` (2.x satisfies the wscript's + `taglib >= 1.9` check, and the API essentia uses — `FileRef`, `PropertyMap`, + `StringList` — survived the taglib 2.0 break), plain `python3`. No pins. +- `zlib` is required: taglib's pkg-config `Libs` pulls in `-lz` and nothing + else in the closure provides it. Symptom if it regresses: all 288 objects + compile, then `ld.bfd: cannot find -lz` while linking `libessentia.so`. +- propagatedBuildInputs: eigen fftwFloat ffmpeg libsamplerate taglib + chromaprint libyaml zlib. +- `eigen` is the one *mandatory* pkg-config check on master (`eigen3 >= 3.3.4`); + every other `check_cfg` in `src/wscript` is `mandatory=False`, so a missing + dep there silently drops algorithms instead of failing the build. Worth + reading the "CONFIGURATION SUMMARY" block in the log — MonoLoader, + MetadataReader, Resample etc. disappear if ffmpeg/taglib/samplerate go + missing, and you only find out when the Rust codegen can't find them. +- installPhase patches essentia.pc Cflags to add `-I${includedir}` + (essentia.pc.in still lists only the per-subdirectory include paths). +- `postPatch` makes `scheduler::Network::lastCreated` `thread_local`. Upstream + it is one process-global pointer, written by every Network ctor/dtor and read + by every `Network::run()`. MonoLoader, RhythmExtractor2013 and KeyExtractor + each build an inner Network, so analysing with `-j >1` corrupts it: warning + spam ("No network created..."), interleaved output, then `free(): double free + detected in tcache`. The same hunk adds the missing `return` after that + warning, which otherwise falls straight into a null dereference. Essentia + supports one-instance-per-thread otherwise (FFTW plans and the devnull/copy + singletons are ForcedMutex-guarded); this global is the outlier. **Report + upstream.** `src/audio_processing.rs` documents that it depends on this. +- To move the pin: `nix hash path --sri --type sha256 `, or set + `hash = lib.fakeHash` and read the correct value out of the build error. ## Current status - [x] Rust integration done (`src/audio_processing.rs` uses MonoLoader → RhythmExtractor2013(multifeature) → KeyExtractor(EDMA)). - [x] flake restructured (path-dep vendoring, single-shot, USE_TENSORFLOW=0). -- [~] `nix build .#essentia` — FIRST attempt failed on the waf/`imp` Python 3.12 - issue. Fix applied (python311). **Rebuild in progress / next step.** -- [ ] `nix build .#virittaa` — not yet attempted. Watch for: essentia-codegen - running libessentia at build time (needs rpath/LD path to essentia + its - deps); the generated module paths / enum variants assumed in +- [x] `nix build .#essentia` — builds clean on master against ffmpeg 8.1, + taglib 2.2.1, eigen 3.4.1, Python 3.14. +- [x] The C++ bridge compiles (after the eigen include fix above). +- [x] essentia-codegen's build script needs `LD_LIBRARY_PATH` — rustc emits + `-L native=...` but no RPATH, so *running* the build script fails with + "libessentia.so: cannot open shared object file", and once that resolves, + "libstdc++.so.6: ..." (the cxx bridge). Both come from the shared + `essentiaLibs` list, exported in `preBuild` and reused by the runtime + wrapper; `stdenv.cc.cc.lib` is in it for libstdc++. + Upstream fix: have essentia_sys's build.rs emit + `cargo:rustc-link-arg=-Wl,-rpath,` per probed library; that would + retire both the preBuild export and the wrapper. +- [x] `nix build .#virittaa` — builds and links. The codegen ran against + master's libessentia, so the module paths and enum variants in audio_processing.rs (`essentia::algorithm::{input_output,rhythm,tonal}`, `RhythmExtractor2013Method::Multifeature`, `KeyExtractorProfileType::Edma`) - — these are unverified until this first real compile. + are confirmed correct. `sqlite` had to be added to buildInputs: + libsqlite3-sys builds unbundled and links the system library. +- [ ] Verify `-j >1` analysis is now clean end to end (this is what surfaced the + `lastCreated` bug above). +- [ ] Drop the `LD_LIBRARY_PATH` wrapper in `postInstall` once the built binary + is confirmed to carry the right RPATH (`patchelf --print-rpath + result/bin/.virittaa-wrapped`). ## Reproduce / continue ```sh cd /home/miro/src/sr.ht/virittaa git add -A # flake (git) only sees tracked/staged files! -nix build .#essentia -L # should now pass with python311 +nix build .#essentia -L # libessentia alone nix build .#virittaa -L # the real test ``` blob - 2dd586e859cb9f4ad4ff93df3ff1784d5080ceff blob + b7cc49ef2fa3e553dd0c918446c23983bebb430b --- nix/essentia.nix +++ nix/essentia.nix @@ -1,43 +1,78 @@ -# Pinned to v2.1_beta5, which requires libavresample (removed in ffmpeg >= 5), -# so we build against ffmpeg_4. taglib is pinned to the 1.x series for the same -# API-compatibility reason. +# Pinned to a master commit (2.1-beta6-dev) rather than the last tag, v2.1_beta5. +# beta5 requires libavresample (removed in ffmpeg 5) and bundles waf 2.0.10, +# which needs Python <= 3.11 (`import imp`) and patching for the removed 'rU' +# open mode. Master resamples with libswresample instead -- matching what +# essentia-rs's essentia_sys probes via pkg-config -- and bundles waf 2.0.25, +# which runs unmodified on current Python. Both pins (ffmpeg_4, taglib_1) and +# the whole waf patching dance are gone as a result. { lib, stdenv, fetchFromGitHub, python3, pkg-config, + eigen, fftwFloat, - ffmpeg_4, + ffmpeg, libsamplerate, - taglib_1, + taglib, chromaprint, libyaml, zlib, }: stdenv.mkDerivation (finalAttrs: { pname = "essentia"; - version = "2.1_beta5"; + version = "2.1-beta6-dev-unstable-2026-05-20"; src = fetchFromGitHub { owner = "MTG"; repo = "essentia"; - rev = "v${finalAttrs.version}"; - sha256 = "0ig267wfxry2rjpij4fypf2zk7f59jd0n23l3807igbn2cmkgz4w"; + rev = "b9fa6cb674ca43dfb94d28d293aeda441c6745db"; + hash = "sha256-8z7SZN6tO/3iB2bOzPnz1en0NjbgGJcTjkj02PzvdVs="; }; + # `scheduler::Network::lastCreated` is a process-global pointer, assigned by + # every Network constructor and cleared by every destructor, and read on every + # `Network::run()` via printNetworkBufferFillState(). Since MonoLoader, + # RhythmExtractor2013 and KeyExtractor each build an inner Network, analysing + # files on more than one thread makes threads clobber each other's pointer: + # "No network created, or last created network has been deleted..." spam, + # interleaved output, then a use-after-free ("double free detected in tcache"). + # + # It is purely a debug convenience, so make it per-thread. Essentia otherwise + # supports one-instance-per-thread use (it guards FFTW plan creation and the + # devnull/copy singletons with ForcedMutex); this global is the outlier. + # The second hunk fixes the null dereference on the line right after the + # warning that says the pointer is null. Both are worth reporting upstream. + postPatch = '' + substituteInPlace src/essentia/scheduler/network.h \ + --replace-fail 'static Network* lastCreated;' \ + 'static thread_local Network* lastCreated;' + substituteInPlace src/essentia/scheduler/network.cpp \ + --replace-fail 'Network* Network::lastCreated = 0;' \ + 'thread_local Network* Network::lastCreated = 0;' \ + --replace-fail 'E_WARNING("No network created, or last created network has been deleted...");' \ + 'E_WARNING("No network created, or last created network has been deleted..."); return;' + ''; + + strictDeps = true; + nativeBuildInputs = [ - python3 + python3 # only runs waf; no Python bindings are built pkg-config ]; # Propagated so anything linking libessentia.so also picks up its runtime # dependencies (the .so records them as NEEDED). propagatedBuildInputs = [ + # The only hard dependency on master (`eigen3 >= 3.3.4`, mandatory); every + # other check_cfg in src/wscript is mandatory=False. Header-only, but + # propagated because essentia's own headers include it. + eigen fftwFloat - ffmpeg_4 + ffmpeg libsamplerate - taglib_1 + taglib chromaprint libyaml # taglib's pkg-config Libs pulls in -lz; nothing else in the closure @@ -45,28 +80,10 @@ stdenv.mkDerivation (finalAttrs: { zlib ]; - # waf 2.0.10's bundled waflib opens files with the universal-newline 'rU' - # mode, which Python 3.11 removed, so waf fails with "invalid mode: 'rUb'". - # The bundled waf also imports the removed 'imp' module, so it must be driven - # by Python 3.11 (passed in as pkgs.python311). We let waf unpack itself - # once, then drop the 'U' flag from every call site (Context.py reads - # wscripts during configure, ConfigSet.py reads the lockfile at the start of - # every later invocation). configurePhase = '' runHook preConfigure + # waf caches its unpacked waflib under $HOME. export HOME=$TMPDIR - - # Unpack the bundled waflib. Python 3.11 can still decode the archive, - # but it cannot use 'rU' once configure starts reading wscript files. - python3 waf --version > /dev/null - - for f in .waf3-*/waflib/Context.py .waf3-*/waflib/ConfigSet.py; do - substituteInPlace "$f" --replace-fail "'rU'" "'r'" - done - - # Use the patched waflib for this and the later build/install phases. - export WAFDIR="$PWD/$(echo .waf3-*)" - python3 waf configure --prefix=$out runHook postConfigure ''; @@ -80,6 +97,9 @@ stdenv.mkDerivation (finalAttrs: { installPhase = '' runHook preInstall python3 waf install + # essentia.pc.in lists only the per-subdirectory include paths, so + # `#include ` (what the essentia-rs bridge writes) does not + # resolve. Add the parent include dir. substituteInPlace $out/lib/pkgconfig/essentia.pc \ --replace-fail 'Cflags: -I''${includedir}/essentia' \ 'Cflags: -I''${includedir} -I''${includedir}/essentia' blob - 6fc9c4084df4226d1ef7990dfb0dd8d4b10907df blob + 97df38831b5402d041b70c32b12fc7c768090ced --- src/audio_processing.rs +++ src/audio_processing.rs @@ -29,6 +29,14 @@ static ESSENTIA: LazyLock = LazyLock::new(Es /// composite extractors below also instantiate inner algorithms through it while /// configuring. virittaa analyzes files in parallel (rayon), so we serialize the /// create+configure phase; the heavy `compute` calls run unlocked. +/// +/// Running `compute` unlocked additionally requires an Essentia whose +/// `scheduler::Network::lastCreated` is `thread_local`. Upstream it is a single +/// process-global pointer that every inner Network's constructor and destructor +/// writes and every `Network::run()` reads, so concurrent analysis corrupts it +/// and aborts with a double free. `nix/essentia.nix` patches it; when building +/// against a distro Essentia, either apply the same patch or hold this lock +/// across the whole function. static FACTORY_LOCK: Mutex<()> = Mutex::new(()); /// Result of analysing a single track.