Commit Diff


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 <unsupported/Eigen/CXX11/Tensor>. 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,<libdir>` 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 <unpacked tree>`, 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,<libdir>` 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 <essentia/...>` (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<Essentia> = 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.