commit - b2fd8f182d36836b3041cc710a6545e88e0f4bd4
commit + e6001f45f7b64607639d592e3e13c555e46a1a89
blob - 9ea5b7226a083bcda854477e9cd265361ff484fb
blob + 65b4df5b4b9c3ca4ad9b45bf7d7fb813e78d5a06
--- .gitignore
+++ .gitignore
result
zilinaplayed
essentia
+void-runbook.org
+nix/BUILD_NOTES.md
blob - 1bcac2c2dfeb8d9ac17ab25daee37af9d6a4b6fd
blob + a93f4fb12587ed5af0fb43b75ba3f92df01cd7ae
--- nix/BUILD_NOTES.md
+++ nix/BUILD_NOTES.md
- 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
#!/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
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
@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"
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
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
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
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"],
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)
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
+#!/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