Commit Diff


commit - b2fd8f182d36836b3041cc710a6545e88e0f4bd4
commit + e6001f45f7b64607639d592e3e13c555e46a1a89
blob - 9ea5b7226a083bcda854477e9cd265361ff484fb
blob + 65b4df5b4b9c3ca4ad9b45bf7d7fb813e78d5a06
--- .gitignore
+++ .gitignore
@@ -2,3 +2,5 @@ target
 result
 zilinaplayed
 essentia
+void-runbook.org
+nix/BUILD_NOTES.md
blob - 1bcac2c2dfeb8d9ac17ab25daee37af9d6a4b6fd
blob + a93f4fb12587ed5af0fb43b75ba3f92df01cd7ae
--- nix/BUILD_NOTES.md
+++ nix/BUILD_NOTES.md
@@ -101,6 +101,31 @@ was removed. `nix build` must build:
 - 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.
 
+## Building outside Nix (Void Linux)
+
+`void-build-essentia.rb` (repo root, run as root) builds libessentia in a
+throwaway Void chroot and emits an installable tarball. It deliberately mirrors
+this derivation: same pinned commit, same two `Network::lastCreated` patches,
+same `essentia.pc` Cflags fixup. Keep the two in sync — `src/audio_processing.rs`
+runs Essentia's `compute` unlocked, which is only safe against a patched build.
+
+`void-build-virittaa.rb` drives that script, installs the resulting essentia
+into the same chroot, then builds virittaa and emits a relocatable bundle
+(binary + private libs, `$ORIGIN/../lib` rpath), optionally unpacking it into
+the invoking user's `~/.local` and smoke-testing it. It applies the same
+essentia_sys eigen include-path patch the flake does.
+
+A fully static binary is not on the table: Void ships no static
+ffmpeg/taglib/chromaprint/fftw, and a statically linked glibc breaks NSS at
+runtime. The `$ORIGIN` bundle is the portable substitute, and unlike the flake's
+`wrapProgram` it needs no `LD_LIBRARY_PATH`.
+
+Void specifics: `ffmpeg6-devel`, not `ffmpeg-devel` (still 4.4.x); `eigen` is
+headers-only with no `-devel`; `zlib-devel` is needed because taglib's
+pkg-config `Libs` pulls in `-lz`; `rust`/`cargo` are 1.97, new enough for
+edition 2024. Whichever ffmpeg virittaa links must be the one essentia was
+built against.
+
 ## Current status
 
 - [x] Rust integration done (`src/audio_processing.rs` uses
blob - 9f3f01db74fa8a2f813650dea7f2067e2c6937a6 (mode 644)
blob + 2365721f10755df280269d44b72f4926bf08b124 (mode 755)
--- void-build-essentia.rb
+++ void-build-essentia.rb
@@ -1,32 +1,78 @@
 #!/usr/bin/env ruby
 # frozen_string_literal: true
 
+# Build libessentia in a throwaway Void Linux chroot and emit an installable
+# tarball.
+#
+# Essentia is not in the Void repos, and virittaa needs it as a *library*
+# (headers + libessentia.so + essentia.pc), not the prebuilt extractor binary.
+# This mirrors what nix/essentia.nix does, so a Void build lines up with the
+# Nix one: same pinned master commit, same two upstream patches, same
+# essentia.pc fixup.
+
 require "fileutils"
 require "open3"
 require "etc"
 
 class Builder
   REPO = "https://repo-default.voidlinux.org/current"
+
+  # The last tag, v2.1_beta5, needs libavresample (gone since ffmpeg 5) and
+  # bundles waf 2.0.10, which cannot run on Python 3.12+. Master resamples with
+  # libswresample and bundles waf 2.0.25, which runs fine on Void's python3.
+  REV = "b9fa6cb674ca43dfb94d28d293aeda441c6745db"
+
+  # ffmpeg6-devel rather than ffmpeg-devel: the latter is still 4.4.x. Either
+  # provides libswresample, but whatever is chosen here must match what virittaa
+  # itself links against. eigen is headers-only (no -devel package). zlib-devel
+  # is required because taglib's pkg-config Libs pulls in -lz.
   DEPS = %w[
-    cmake
-    make
-    gcc
-    qt5-devel
-    qt5-svg-devel
-    qt5-multimedia-devel
-    qt5-declarative-devel
-    qt5-webkit-devel
-    hunspell-devel
-    gstreamer1-devel
-    libxml2-devel
-    qt5-tools
+    base-devel
+    python3
+    pkg-config
+    eigen
+    fftw-devel
+    ffmpeg6-devel
+    libsamplerate-devel
+    taglib-devel
+    chromaprint-devel
+    libyaml-devel
+    zlib-devel
   ]
     .freeze
 
+  # `scheduler::Network::lastCreated` is a process-global pointer written by
+  # every Network constructor/destructor and read on every `Network::run()`.
+  # MonoLoader, RhythmExtractor2013 and KeyExtractor each build an inner
+  # Network, so analysing on more than one thread corrupts it and aborts with a
+  # double free. It is only a debug convenience, so make it per-thread. The
+  # third hunk fixes the null dereference on the line after the warning that
+  # says the pointer is null. Both are worth reporting upstream.
+  PATCHES = [
+    [
+      "src/essentia/scheduler/network.h",
+      "static Network* lastCreated;",
+      "static thread_local Network* lastCreated;"
+    ],
+    [
+      "src/essentia/scheduler/network.cpp",
+      "Network* Network::lastCreated = 0;",
+      "thread_local Network* Network::lastCreated = 0;"
+    ],
+    [
+      "src/essentia/scheduler/network.cpp",
+      '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;'
+    ]
+  ].freeze
+
   def initialize(argv)
     @outdir = nil
-    @chroot = "/var/chroot/otter-build"
-    @keep = false
+    @chroot = "/var/chroot/essentia-build"
+    @rev = REV
+    @src = nil
+    @prefix = "/usr"
+    @delete = false
     @quiet = false
     parse(argv)
     validate
@@ -37,11 +83,13 @@ class Builder
     mount_vfs
     prep_chroot
     install_deps
+    stage_source
+    patch_source
     build
     extract
   ensure
     unmount_vfs
-    remove_chroot unless @keep
+    remove_chroot if @delete
     msg("done.")
   end
 
@@ -55,8 +103,14 @@ class Builder
         @outdir = argv[i += 1]
       when "-c"
         @chroot = argv[i += 1]
-      when "-k"
-        @keep = true
+      when "-r"
+        @rev = argv[i += 1]
+      when "-s"
+        @src = argv[i += 1]
+      when "-p"
+        @prefix = argv[i += 1]
+      when "-d"
+        @delete = true
       when "-q"
         @quiet = true
       when "-h", "--help"
@@ -71,11 +125,14 @@ class Builder
 
   def usage_lines
     [
-      "Usage: #{File.basename($0)} [-o DIR] [-c DIR] [-k] [-q]",
-      "  -o DIR   output directory (default: invoking user home)",
-      "  -c DIR   chroot directory (default: /var/chroot/otter-build)",
-      "  -k       keep chroot after build",
-      "  -q       quiet"
+      "Usage: #{File.basename($0)} [-o DIR] [-c DIR] [-r REV] [-s DIR] [-p PREFIX] [-d] [-q]",
+      "  -o DIR     output directory (default: invoking user home)",
+      "  -c DIR     chroot directory (default: /var/chroot/essentia-build)",
+      "  -r REV     essentia git revision (default: #{REV[0, 12]})",
+      "  -s DIR     build this source tree instead of fetching REV",
+      "  -p PREFIX  install prefix inside the tarball (default: /usr)",
+      "  -d         delete chroot after build (default: keep)",
+      "  -q         quiet"
     ]
   end
 
@@ -90,23 +147,35 @@ class Builder
       die("must run as root")
     end
 
-    @src = Dir.pwd
-    die("no CMakeLists.txt in #{@src}") unless File.file?(File.join(@src, "CMakeLists.txt"))
+    if @src
+      @src = File.expand_path(@src)
+      die("no wscript in #{@src}") unless File.file?(File.join(@src, "wscript"))
+      die("#{@src} does not look like essentia") unless File.directory?(File.join(@src, "src/essentia"))
+    end
 
     inv = ENV["SUDO_USER"] || ENV["DOAS_USER"]
     if inv
-      @pw = Etc.getpwnam(inv) rescue nil
+      @pw = begin
+        Etc.getpwnam(inv)
+      rescue
+        nil
+      end
       die("cannot resolve user #{inv}") unless @pw
       @outdir ||= @pw.dir
     else
       @outdir ||= File.expand_path("~")
     end
+
+    @build_root = File.join(@chroot, "src/essentia")
+    @stage = File.join(@chroot, "staging")
   end
 
-  def msg(s) = puts(s) unless @quiet
+  def msg(s)
+    puts(s) unless @quiet
+  end
 
   def die(s)
-    $stderr.puts("#{File.basename($0)}: #{s}")
+    warn("#{File.basename($0)}: #{s}")
     exit(1)
   end
 
@@ -117,9 +186,15 @@ class Builder
     st.success?
   end
 
-  def chroot_cmd(script)
-    msg("chroot #{@chroot} /bin/bash -c '#{script}'")
-    die("chroot command failed") unless system("chroot #{@chroot} /bin/bash -c '#{script}'")
+  # Run a shell snippet inside the chroot by dropping it in a file and invoking
+  # bash on it, rather than interpolating into a quoted -c string. Keeps the
+  # build scripts free to use quotes, $(...) and newlines.
+  def chroot_sh(label, script)
+    path = File.join(@chroot, "tmp/step.sh")
+    FileUtils.mkdir_p(File.dirname(path))
+    File.write(path, "set -e\n#{script}")
+    msg(label)
+    die("#{label} failed") unless system("chroot", @chroot, "/bin/bash", "/tmp/step.sh")
   end
 
   def bootstrap
@@ -127,15 +202,18 @@ class Builder
     return if File.executable?(File.join(@chroot, "bin/bash"))
 
     msg("bootstrapping chroot...")
-    kdir = File.join(@chroot, "var/db/xbps/keys")
-    FileUtils.mkdir_p(kdir)
-    Dir.glob("/var/db/xbps/keys/*").each { |f| FileUtils.cp(f, kdir) }
-
+    copy_keys
     run_cmd("xbps-install -Syu -R #{REPO} -r #{@chroot} base-voidstrap", fatal: false)
     die("bootstrap failed") unless File.executable?(File.join(@chroot, "bin/bash"))
     msg("bootstrap ok")
   end
 
+  def copy_keys
+    kdir = File.join(@chroot, "var/db/xbps/keys")
+    FileUtils.mkdir_p(kdir)
+    Dir.glob("/var/db/xbps/keys/*").each { |f| FileUtils.cp(f, kdir) }
+  end
+
   def mount_vfs
     mounts = {
       File.join(@chroot, "proc") => ["-t", "proc", "proc"],
@@ -155,43 +233,95 @@ class Builder
 
   def prep_chroot
     FileUtils.cp("/etc/resolv.conf", File.join(@chroot, "etc/resolv.conf"))
-    kdir = File.join(@chroot, "var/db/xbps/keys")
-    FileUtils.mkdir_p(kdir)
-    Dir.glob("/var/db/xbps/keys/*").each { |f| FileUtils.cp(f, kdir) }
+    copy_keys
   end
 
   def install_deps
-    chroot_cmd("xbps-install -Syu")
-    chroot_cmd("xbps-install -S #{DEPS.join(" ")}")
+    chroot_sh("installing deps", <<~SH)
+      xbps-install -Syu
+      xbps-install -Sy #{DEPS.join(" ")}
+    SH
   end
 
+  # Fetched on the host rather than in the chroot, so the chroot needs neither
+  # curl nor ca-certificates.
+  def stage_source
+    FileUtils.rm_rf(@build_root)
+    FileUtils.mkdir_p(File.dirname(@build_root))
+
+    if @src
+      msg("copying #{@src}")
+      system("cp", "-a", @src, @build_root) || die("copy failed")
+      return
+    end
+
+    tarball = File.join(@chroot, "tmp/essentia.tar.gz")
+    FileUtils.mkdir_p(File.dirname(tarball))
+    url = "https://github.com/MTG/essentia/archive/#{@rev}.tar.gz"
+    run_cmd("curl -fsSL -o #{tarball} #{url}")
+
+    FileUtils.mkdir_p(@build_root)
+    run_cmd("tar xzf #{tarball} -C #{@build_root} --strip-components=1")
+    FileUtils.rm_f(tarball)
+  end
+
+  # Applied on the host, on the copied tree. Idempotent, and fatal if a pattern
+  # has moved -- silently skipping would produce a library that crashes only
+  # under concurrency, which is exactly the failure this prevents.
+  def patch_source
+    PATCHES.each do |rel, from, to|
+      path = File.join(@build_root, rel)
+      die("missing #{rel}") unless File.file?(path)
+
+      txt = File.read(path)
+      if txt.include?(to)
+        msg("already patched: #{rel}")
+        next
+      end
+      die("#{rel}: pattern not found: #{from}") unless txt.include?(from)
+
+      File.write(path, txt.sub(from, to))
+      msg("patched #{rel}")
+    end
+  end
+
   def build
-    dst = File.join(@chroot, "src/otter-browser")
-    FileUtils.rm_rf(dst)
-    FileUtils.mkdir_p(File.dirname(dst))
-    system("cp -a #{@src} #{dst}") || die("copy failed")
+    FileUtils.rm_rf(@stage)
+    chroot_sh("building essentia", <<~SH)
+      export HOME=/tmp
+      cd /src/essentia
+      python3 waf configure --prefix=#{@prefix}
+      python3 waf -j$(nproc)
+      python3 waf install --destdir=/staging
+    SH
 
-    cm = File.join(dst, "CMakeLists.txt")
-    txt = File.read(cm)
-    unless txt.include?("FATAL_ON_MISSING_REQUIRED_PACKAGES")
-      die("CMakeLists.txt: FATAL_ON_MISSING_REQUIRED_PACKAGES not found")
-    end
+    lib = File.join(@stage, @prefix.delete_prefix("/"), "lib/libessentia.so")
+    die("libessentia.so not found at #{lib}") unless File.file?(lib)
 
-    txt.gsub!("FATAL_ON_MISSING_REQUIRED_PACKAGES", "")
-    File.write(cm, txt)
+    fix_pkgconfig
+  end
 
-    script = "set -e; cd /src/otter-browser; rm -rf build; mkdir build; cd build; cmake -DENABLE_QT5=ON ../; make -j$(nproc)"
-    chroot_cmd(script)
+  # essentia.pc lists only the per-subdirectory include paths, so the
+  # `#include <essentia/...>` that essentia-rs's bridge emits does not resolve.
+  # Add the parent include dir, exactly as nix/essentia.nix does.
+  def fix_pkgconfig
+    pc = File.join(@stage, @prefix.delete_prefix("/"), "lib/pkgconfig/essentia.pc")
+    die("essentia.pc not found at #{pc}") unless File.file?(pc)
+
+    txt = File.read(pc)
+    from = "Cflags: -I${includedir}/essentia"
+    to = "Cflags: -I${includedir} -I${includedir}/essentia"
+    return if txt.include?(to)
+    die("essentia.pc: Cflags line not found") unless txt.include?(from)
+
+    File.write(pc, txt.sub(from, to))
+    msg("patched essentia.pc")
   end
 
   def extract
-    bin = File.join(@chroot, "src/otter-browser/build/otter-browser")
-    die("binary not found") unless File.file?(bin)
+    out = File.join(@outdir, "essentia-#{@rev[0, 12]}-#{`uname -m`.strip}.tar.gz")
+    run_cmd("tar czf #{out} -C #{@stage} .")
 
-    out = File.join(@outdir, "otter-browser")
-    system("cp #{bin} #{out}") || die("copy binary failed")
-    FileUtils.chmod("+x", out)
-
     if @pw
       begin
         File.chown(@pw.uid, @pw.gid, out)
@@ -200,7 +330,8 @@ class Builder
       end
     end
 
-    msg("binary: #{out}")
+    msg("tarball: #{out}")
+    msg("install with: tar xzf #{out} -C /")
   end
 
   def remove_chroot
blob - /dev/null
blob + 473cf300aa7e7a51d4a25f264be3ce77c32edfd1 (mode 755)
--- /dev/null
+++ void-build-virittaa.rb
@@ -0,0 +1,437 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# Build virittaa in a throwaway Void Linux chroot and install a relocatable
+# bundle into the invoking user's ~/.local.
+#
+# Drives void-build-essentia.rb first (libessentia is not in the Void repos),
+# installs the result into the chroot, then builds virittaa against it and
+# bundles the binary with its non-glibc shared libraries.
+#
+# On "static": a fully static binary is not achievable here. Void ships no
+# static ffmpeg/taglib/chromaprint/fftw, and statically linking glibc breaks
+# NSS at runtime. What this produces instead is a *relocatable* bundle -- the
+# binary plus its private libs, wired up with an $ORIGIN rpath -- which runs
+# from any directory without LD_LIBRARY_PATH and without installing essentia
+# system-wide.
+
+require "fileutils"
+require "open3"
+require "etc"
+
+class Builder
+  ESSENTIA_SCRIPT = "void-build-essentia.rb"
+
+  DEPS = %w[
+    rust
+    cargo
+    git
+    clang
+    sqlite-devel
+    patchelf
+  ]
+    .freeze
+
+  # Only what cargo needs. The virittaa working directory may also hold a music
+  # library, which must not be copied into the chroot.
+  SRC_ITEMS = %w[Cargo.toml Cargo.lock src .cargo].freeze
+
+  # Provided by the host's glibc and loader; bundling these is what breaks a
+  # relocatable tree rather than what saves it.
+  SYSTEM_LIBS = %w[
+    linux-vdso.so.1
+    ld-linux-x86-64.so.2
+    libc.so.6
+    libm.so.6
+    libpthread.so.0
+    libdl.so.2
+    librt.so.1
+    libresolv.so.2
+  ].freeze
+
+  def initialize(argv)
+    @outdir = nil
+    @chroot = "/var/chroot/virittaa-build"
+    @src = Dir.pwd
+    @essentia_rs = nil
+    @rev = nil
+    @install = true
+    @delete = false
+    @quiet = false
+    parse(argv)
+    validate
+  end
+
+  def run
+    build_essentia
+    # The essentia builder unmounts the vfs on its way out; cargo and rustc
+    # need /dev/null and /proc, so put them back.
+    mount_vfs
+    install_essentia
+    install_deps
+    stage_source
+    build
+    bundle
+    package
+    install_local if @install
+  ensure
+    unmount_vfs
+    remove_chroot if @delete
+    msg("done.")
+  end
+
+  private
+
+  def parse(argv)
+    i = 0
+    while i < argv.length
+      case argv[i]
+      when "-o"
+        @outdir = argv[i += 1]
+      when "-c"
+        @chroot = argv[i += 1]
+      when "-s"
+        @src = argv[i += 1]
+      when "-e"
+        @essentia_rs = argv[i += 1]
+      when "-r"
+        @rev = argv[i += 1]
+      when "-n"
+        @install = false
+      when "-d"
+        @delete = true
+      when "-q"
+        @quiet = true
+      when "-h", "--help"
+        usage(code: 0)
+      else
+        usage
+      end
+
+      i += 1
+    end
+  end
+
+  def usage_lines
+    [
+      "Usage: #{File.basename($0)} [-o DIR] [-c DIR] [-s DIR] [-e DIR] [-r REV] [-n] [-d] [-q]",
+      "  -o DIR   output directory for the tarball (default: invoking user home)",
+      "  -c DIR   chroot directory (default: /var/chroot/virittaa-build)",
+      "  -s DIR   virittaa source (default: current directory)",
+      "  -e DIR   essentia-rs source (default: SRC/../essentia-rs)",
+      "  -r REV   essentia git revision (passed to #{ESSENTIA_SCRIPT})",
+      "  -n       build only, do not install into ~/.local",
+      "  -d       delete chroot after build (default: keep)",
+      "  -q       quiet"
+    ]
+  end
+
+  def usage(code: 1)
+    usage_lines.each { |l| puts(l) }
+    exit(code)
+  end
+
+  def validate
+    unless Process.uid.zero?
+      usage_lines.each { |l| puts(l) }
+      die("must run as root")
+    end
+
+    @src = File.expand_path(@src)
+    die("no Cargo.toml in #{@src}") unless File.file?(File.join(@src, "Cargo.toml"))
+
+    @essentia_script = File.join(@src, ESSENTIA_SCRIPT)
+    die("#{ESSENTIA_SCRIPT} not found next to the source") unless File.file?(@essentia_script)
+
+    @essentia_rs = File.expand_path(@essentia_rs || File.join(@src, "..", "essentia-rs"))
+    unless File.file?(File.join(@essentia_rs, "essentia_sys", "build.rs"))
+      die("no essentia-rs checkout at #{@essentia_rs} (use -e DIR)")
+    end
+
+    inv = ENV["SUDO_USER"] || ENV["DOAS_USER"]
+    if inv
+      @pw = begin
+        Etc.getpwnam(inv)
+      rescue
+        nil
+      end
+      die("cannot resolve user #{inv}") unless @pw
+      @outdir ||= @pw.dir
+    else
+      @outdir ||= File.expand_path("~")
+      @install = false if @install && !@pw
+    end
+
+    @build_root = File.join(@chroot, "src/virittaa")
+    @dist = File.join(@chroot, "dist/virittaa")
+    @binary = File.join(@build_root, "target/release/virittaa")
+  end
+
+  def msg(s)
+    puts(s) unless @quiet
+  end
+
+  def die(s)
+    warn("#{File.basename($0)}: #{s}")
+    exit(1)
+  end
+
+  def run_cmd(cmd, fatal: true)
+    msg(cmd)
+    _o, e, st = Open3.capture3(cmd)
+    die("#{cmd}: #{e.strip}") if !st.success? && fatal
+    st.success?
+  end
+
+  def chroot_sh(label, script)
+    path = File.join(@chroot, "tmp/step.sh")
+    FileUtils.mkdir_p(File.dirname(path))
+    File.write(path, "set -e\n#{script}")
+    msg(label)
+    die("#{label} failed") unless system("chroot", @chroot, "/bin/bash", "/tmp/step.sh")
+  end
+
+  def chroot_capture(script)
+    path = File.join(@chroot, "tmp/query.sh")
+    FileUtils.mkdir_p(File.dirname(path))
+    File.write(path, "set -e\n#{script}")
+    out, _e, st = Open3.capture3("chroot", @chroot, "/bin/bash", "/tmp/query.sh")
+    die("chroot query failed") unless st.success?
+    out
+  end
+
+  # Copy a tree without dragging in build output (or, for the virittaa source,
+  # anything not on the allowlist).
+  def copy_tree(src, dst, items: nil, excludes: %w[./target])
+    FileUtils.mkdir_p(dst)
+    if items
+      items.each do |item|
+        path = File.join(src, item)
+        next unless File.exist?(path)
+        system("cp", "-a", path, dst) || die("copy #{item} failed")
+      end
+    else
+      args = excludes.map { |e| "--exclude=#{e}" }.join(" ")
+      run_cmd("tar -C #{src} -cf - #{args} . | tar -C #{dst} -xf -")
+    end
+  end
+
+  # The essentia builder owns bootstrapping the chroot; it keeps it by default,
+  # and its tarball lands in the chroot's own tmp. -d is deliberately never
+  # passed down: this script shares that chroot and tears it down itself.
+  def build_essentia
+    FileUtils.mkdir_p(File.join(@chroot, "tmp"))
+    cmd = ["ruby", @essentia_script, "-c", @chroot, "-o", File.join(@chroot, "tmp")]
+    cmd += ["-r", @rev] if @rev
+    cmd << "-q" if @quiet
+
+    msg(cmd.join(" "))
+    die("essentia build failed") unless system(*cmd)
+  end
+
+  def install_essentia
+    tarballs = Dir.glob(File.join(@chroot, "tmp/essentia-*.tar.gz"))
+    die("no essentia tarball produced") if tarballs.empty?
+    inner = "/tmp/#{File.basename(tarballs.max_by { |f| File.mtime(f) })}"
+
+    chroot_sh("installing essentia into chroot", <<~SH)
+      tar xzf #{inner} -C /
+      ldconfig
+      pkg-config --exists essentia
+      echo "essentia $(pkg-config --modversion essentia) ok"
+    SH
+  end
+
+  def install_deps
+    chroot_sh("installing rust toolchain and build deps", <<~SH)
+      xbps-install -Sy #{DEPS.join(" ")}
+    SH
+  end
+
+  # The source is re-staged from scratch every run, but the chroot now persists
+  # by default, so carry target/ across so a rebuild is incremental rather than
+  # recompiling the whole dependency tree.
+  def stage_source
+    target = File.join(@build_root, "target")
+    cache = File.join(@chroot, "src/target-cache")
+    if File.directory?(target)
+      FileUtils.rm_rf(cache)
+      FileUtils.mv(target, cache)
+      msg("preserving cargo target/ for incremental rebuild")
+    end
+
+    FileUtils.rm_rf(@build_root)
+    FileUtils.mkdir_p(@build_root)
+    FileUtils.mv(cache, target) if File.directory?(cache)
+
+    msg("staging virittaa source")
+    copy_tree(@src, @build_root, items: SRC_ITEMS)
+
+    vendor = File.join(@build_root, "vendor-essentia-rs")
+    msg("staging essentia-rs from #{@essentia_rs}")
+    copy_tree(@essentia_rs, vendor)
+
+    rewrite_path_dep
+    patch_eigen_include
+  end
+
+  # The sibling path dependency does not exist inside the chroot.
+  def rewrite_path_dep
+    manifest = File.join(@build_root, "Cargo.toml")
+    txt = File.read(manifest)
+    from = "../essentia-rs/essentia"
+    to = "vendor-essentia-rs/essentia"
+    return if txt.include?(to)
+    die("Cargo.toml: path dependency #{from} not found") unless txt.include?(from)
+
+    File.write(manifest, txt.sub(from, to))
+    msg("rewrote essentia path dependency")
+  end
+
+  # essentia_sys's build.rs appends "eigen3" to every include path eigen3.pc
+  # reports. Void's eigen3.pc already points at .../include/eigen3, so the
+  # bridge would look in include/eigen3/eigen3 and fail on
+  # <unsupported/Eigen/CXX11/Tensor>. Same fix the flake applies; drop this
+  # once essentia-rs handles both layouts.
+  def patch_eigen_include
+    path = File.join(@build_root, "vendor-essentia-rs/essentia_sys/build.rs")
+    die("missing vendored essentia_sys/build.rs") unless File.file?(path)
+
+    txt = File.read(path)
+    from = 'if library.name == "eigen3" {'
+    to = 'if library.name == "eigen3" && !include_path.join("Eigen").is_dir() {'
+    return if txt.include?(to)
+    die("essentia_sys/build.rs: eigen guard not found") unless txt.include?(from)
+
+    File.write(path, txt.sub(from, to))
+    msg("patched essentia_sys eigen include path")
+  end
+
+  def build
+    chroot_sh("building virittaa (this takes a while)", <<~SH)
+      export HOME=/tmp
+      export CARGO_HOME=/tmp/cargo
+      export USE_TENSORFLOW=0
+      export LIBCLANG_PATH=/usr/lib
+      cd /src/virittaa
+      cargo build --release --locked
+    SH
+
+    die("virittaa binary not found") unless File.file?(@binary)
+  end
+
+  # Copy the binary plus every non-glibc shared library it resolves, then point
+  # its rpath at the private lib directory. $ORIGIN is resolved by the loader
+  # relative to the real path of the binary, so the tree can be moved or
+  # symlinked into ~/.local/bin without breaking.
+  def bundle
+    FileUtils.rm_rf(@dist)
+    FileUtils.mkdir_p(File.join(@dist, "bin"))
+    FileUtils.mkdir_p(File.join(@dist, "lib"))
+
+    FileUtils.cp(@binary, File.join(@dist, "bin/virittaa"))
+
+    ldd = chroot_capture("ldd /src/virittaa/target/release/virittaa")
+
+    missing = ldd.lines.filter_map { |l| l[/^\s*(\S+)\s+=>\s+not found/, 1] }
+    die("unresolved libraries: #{missing.join(", ")}") unless missing.empty?
+
+    libs = ldd
+      .lines
+      .filter_map { |l| l[%r{=>\s+(/\S+)}, 1] }
+      .uniq
+      .reject { |p| SYSTEM_LIBS.include?(File.basename(p)) }
+
+    die("ldd resolved no libraries") if libs.empty?
+
+    libs.each do |lib|
+      host_path = File.join(@chroot, lib.delete_prefix("/"))
+      next unless File.file?(host_path)
+      FileUtils.cp(host_path, File.join(@dist, "lib", File.basename(lib)))
+    end
+    msg("bundled #{libs.length} libraries")
+
+    chroot_sh("setting rpaths", <<~SH)
+      patchelf --set-rpath '$ORIGIN/../lib' /dist/virittaa/bin/virittaa
+      for so in /dist/virittaa/lib/*.so*; do
+        patchelf --set-rpath '$ORIGIN' "$so" 2>/dev/null || true
+      done
+      /dist/virittaa/bin/virittaa --help > /dev/null
+      echo "in-chroot smoke test ok"
+    SH
+  end
+
+  def package
+    @tarball = File.join(@outdir, "virittaa-#{version}-#{`uname -m`.strip}.tar.gz")
+    run_cmd("tar czf #{@tarball} -C #{File.dirname(@dist)} virittaa")
+    chown_to_user(@tarball)
+    msg("tarball: #{@tarball}")
+  end
+
+  def version
+    File.read(File.join(@src, "Cargo.toml"))[/^version\s*=\s*"([^"]+)"/, 1] || "0"
+  end
+
+  # Unpack into the invoking user's ~/.local and prove it runs outside the
+  # chroot, which is the whole point of the $ORIGIN rpath.
+  def install_local
+    target = File.join(@pw.dir, ".local/lib/virittaa")
+    bindir = File.join(@pw.dir, ".local/bin")
+    FileUtils.mkdir_p(File.dirname(target))
+    FileUtils.mkdir_p(bindir)
+    FileUtils.rm_rf(target)
+
+    run_cmd("tar xzf #{@tarball} -C #{File.dirname(target)}")
+
+    link = File.join(bindir, "virittaa")
+    FileUtils.rm_f(link)
+    FileUtils.ln_s(File.join(target, "bin/virittaa"), link)
+
+    chown_recursive(target)
+    chown_to_user(link)
+
+    out, _e, st = Open3.capture3(link, "--help")
+    die("installed binary does not run") unless st.success?
+    msg(out.lines.first.to_s.strip)
+    msg("installed: #{link}")
+    msg("add ~/.local/bin to PATH if it is not there already")
+  end
+
+  def chown_to_user(path)
+    return unless @pw
+    File.lchown(@pw.uid, @pw.gid, path)
+  rescue => e
+    msg("chown failed: #{e}")
+  end
+
+  def chown_recursive(dir)
+    return unless @pw
+    FileUtils.chown_R(@pw.uid, @pw.gid, dir)
+  rescue => e
+    msg("chown failed: #{e}")
+  end
+
+  def mount_vfs
+    mounts = {
+      File.join(@chroot, "proc") => ["-t", "proc", "proc"],
+      File.join(@chroot, "sys") => ["-t", "sysfs", "sys"],
+      File.join(@chroot, "dev") => ["--bind", "/dev"]
+    }
+    mounts.each do |dst, opts|
+      FileUtils.mkdir_p(dst)
+      next if system("mountpoint -q #{dst} 2>/dev/null")
+      system("mount #{opts.join(" ")} #{dst}")
+    end
+  end
+
+  def unmount_vfs
+    %w[dev sys proc].each { |n| system("umount #{File.join(@chroot, n)} 2>/dev/null") }
+  end
+
+  def remove_chroot
+    msg("removing #{@chroot}...")
+    FileUtils.rm_rf(@chroot)
+  end
+end
+
+Builder.new(ARGV).run