Commit Diff


commit - 51ec796bc07ae3316c9f9cbfc51308b778e47056
commit + f9a9892f265e00cf11716156b76d7a8cf8f5cdc8
blob - dd626ae9e583100ac7eb428504bfaf608c7072e5
blob + 54aacdf2f6fcd019df2da57d3db7c3be64289122
--- hack/void-build.rb
+++ hack/void-build.rb
@@ -18,446 +18,451 @@ require "fileutils"
 require "open3"
 require "optparse"
 
-class Build
-  VOID_REPO = "https://repo-default.voidlinux.org/current"
+PROG = File.basename($PROGRAM_NAME)
 
-  # Kept in sync with nix/essentia.nix.
-  ESSENTIA_REV = "b9fa6cb674ca43dfb94d28d293aeda441c6745db"
+VOID_REPO = "https://repo-default.voidlinux.org/current"
 
-  # The git dependency in Cargo.toml; cloned unless -e gives a local checkout.
-  ESSENTIA_RS_URL = "ssh://anonymous@mtmn.name/essentia-rs.git"
+# Kept in sync with nix/essentia.nix.
+ESSENTIA_REV = "b9fa6cb674ca43dfb94d28d293aeda441c6745db"
 
-  # ffmpeg6-devel rather than ffmpeg-devel, which is still 4.4.x; either
-  # provides libswresample, but essentia and virittaa must link the same one.
-  # eigen is headers-only (no -devel package). zlib-devel is needed because
-  # taglib's pkg-config Libs pulls in -lz.
-  DEPS = %w[
-    base-devel python3 pkg-config
-    eigen fftw-devel ffmpeg6-devel libsamplerate-devel taglib-devel
-    chromaprint-devel libyaml-devel zlib-devel
-    rust cargo git clang sqlite-devel patchelf
-  ].freeze
+# The git dependency in Cargo.toml; cloned unless -e gives a local checkout.
+ESSENTIA_RS_URL = "ssh://anonymous@mtmn.name/essentia-rs.git"
 
-  # Only what cargo needs: the working directory may also hold a music library.
-  SOURCE_ITEMS = %w[Cargo.toml Cargo.lock src .cargo].freeze
+# ffmpeg6-devel rather than ffmpeg-devel, which is still 4.4.x; either
+# provides libswresample, but essentia and virittaa must link the same one.
+# eigen is headers-only (no -devel package). zlib-devel is needed because
+# taglib's pkg-config Libs pulls in -lz.
+DEPS = %w[
+  base-devel python3 pkg-config
+  eigen fftw-devel ffmpeg6-devel libsamplerate-devel taglib-devel
+  chromaprint-devel libyaml-devel zlib-devel
+  rust cargo git clang sqlite-devel patchelf
+].freeze
 
-  # Provided by the host's glibc and loader; bundling them breaks the tree.
-  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
+# Only what cargo needs: the working directory may also hold a music library.
+SOURCE_ITEMS = %w[Cargo.toml Cargo.lock src .cargo].freeze
 
-  VFS = {
-    "proc" => ["-t", "proc", "proc"],
-    "sys" => ["-t", "sysfs", "sys"],
-    "dev" => ["--bind", "/dev"]
-  }.freeze
+# Provided by the host's glibc and loader; bundling them breaks the tree.
+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)
-    @chroot = "/var/chroot/virittaa-build"
-    @source = Dir.pwd
-    @rev = ESSENTIA_REV
-    @install = true
-    @essentia_only = false
-    @delete = false
-    @quiet = false
-    option_parser.parse!(argv)
-    validate
+VFS = {
+  "proc" => ["-t", "proc", "proc"],
+  "sys" => ["-t", "sysfs", "sys"],
+  "dev" => ["--bind", "/dev"]
+}.freeze
+
+OPT = {
+  chroot: "/var/chroot/virittaa-build",
+  source: Dir.pwd,
+  rev: ESSENTIA_REV,
+  install: true,
+  essentia_only: false,
+  delete: false,
+  quiet: false
+}
+
+# plumbing
+
+def msg(text)
+  puts(text) unless OPT[:quiet]
+end
+
+def die(text)
+  warn("#{PROG}: #{text}")
+  exit(1)
+end
+
+def sh(*cmd)
+  msg(cmd.join(" "))
+  _out, err, status = Open3.capture3(*cmd)
+  die("#{cmd.first} failed: #{err.strip}") unless status.success?
+end
+
+def chroot_path(*parts) = File.join(OPT[:chroot], *parts)
+
+def essentia_root = chroot_path("src/essentia")
+def staging = chroot_path("staging")
+def build_root = chroot_path("src/virittaa")
+def dist = chroot_path("dist/virittaa")
+def binary = File.join(build_root, "target/release/virittaa")
+
+# Via a file rather than a quoted -c string, so snippets can use quotes,
+# $(...) and newlines freely.
+def chroot_sh(label, script)
+  path = chroot_path("tmp/step.sh")
+  FileUtils.mkdir_p(File.dirname(path))
+  File.write(path, "set -e\n#{script}")
+  msg(label)
+  die("#{label} failed") unless system("chroot", OPT[:chroot], "/bin/bash", "/tmp/step.sh")
+end
+
+def chroot_capture(*cmd)
+  out, _err, status = Open3.capture3("chroot", OPT[:chroot], *cmd)
+  die("#{cmd.join(" ")} failed in chroot") unless status.success?
+  out
+end
+
+# A moved pattern is fatal: skipping it yields a build that breaks much later.
+def rewrite(path, from, to, label)
+  die("missing #{path}") unless File.file?(path)
+
+  text = File.read(path)
+  return if text.include?(to)
+  die("#{path}: pattern not found: #{from}") unless text.include?(from)
+
+  File.write(path, text.sub(from, to))
+  msg(label)
+end
+
+def copy_tree(src, dst, items: nil)
+  FileUtils.mkdir_p(dst)
+  entries = items || (Dir.children(src) - ["target", ".git"])
+  entries.each do |item|
+    path = File.join(src, item)
+    FileUtils.cp_r(path, dst, preserve: true) if File.exist?(path)
   end
+end
 
-  def run
-    bootstrap
-    mount_vfs
-    install_deps
-    build_essentia
-    return if @essentia_only
+def owned_by_user(path, recursive: false)
+  user = OPT[:user] or return
 
+  if recursive
+    FileUtils.chown_R(user.uid, user.gid, path)
+  else
+    File.lchown(user.uid, user.gid, path)
+  end
+rescue SystemCallError => e
+  msg("chown #{path} failed: #{e}")
+end
+
+# options
+
+def parse_args(argv)
+  OptionParser.new do |o|
+    o.banner = "Usage: #{PROG} [options]"
+    o.on("-c", "--chroot DIR", "chroot directory (default: #{OPT[:chroot]})") { OPT[:chroot] = it }
+    o.on("-s", "--source DIR", "virittaa source (default: current directory)") { OPT[:source] = it }
+    o.on("-e", "--essentia-rs DIR", "local essentia-rs checkout (default: clone #{ESSENTIA_RS_URL})") { OPT[:essentia_rs] = it }
+    o.on("-o", "--output DIR", "tarball output directory (default: invoking user's home)") { OPT[:output] = it }
+    o.on("-r", "--rev REV", "essentia git revision (default: #{ESSENTIA_REV[0, 12]})") { OPT[:rev] = it }
+    o.on("--essentia-src DIR", "build this essentia tree instead of fetching REV") { OPT[:essentia_src] = it }
+    o.on("--essentia-only", "build libessentia, emit a tarball, and stop") { OPT[:essentia_only] = true }
+    o.on("-n", "--no-install", "build only, do not install into ~/.local") { OPT[:install] = false }
+    o.on("-d", "--delete", "delete the chroot when done (default: keep)") { OPT[:delete] = true }
+    o.on("-q", "--quiet", "only report errors") { OPT[:quiet] = true }
+    o.on("-h", "--help", "show this message") do
+      puts(o)
+      exit
+    end
+  end.parse!(argv)
+rescue OptionParser::ParseError => e
+  die(e.message)
+end
+
+def validate
+  die("must run as root") unless Process.uid.zero?
+
+  OPT[:source] = File.expand_path(OPT[:source])
+
+  unless OPT[:essentia_only]
+    die("no Cargo.toml in #{OPT[:source]}") unless File.file?(File.join(OPT[:source], "Cargo.toml"))
+    if OPT[:essentia_rs]
+      OPT[:essentia_rs] = File.expand_path(OPT[:essentia_rs])
+      unless File.file?(File.join(OPT[:essentia_rs], "essentia_sys", "build.rs"))
+        die("#{OPT[:essentia_rs]} does not look like an essentia-rs checkout")
+      end
+    end
+  end
+
+  if OPT[:essentia_src]
+    OPT[:essentia_src] = File.expand_path(OPT[:essentia_src])
+    die("#{OPT[:essentia_src]} does not look like essentia") unless File.directory?(File.join(OPT[:essentia_src], "src/essentia"))
+  end
+
+  invoking_user = ENV["SUDO_USER"] || ENV["DOAS_USER"]
+  if invoking_user
+    OPT[:user] = begin
+      Etc.getpwnam(invoking_user)
+    rescue ArgumentError
+      die("cannot resolve user #{invoking_user}")
+    end
+  end
+  # Nothing to install into without an invoking user; root's home is not it.
+  OPT[:install] = false unless OPT[:user]
+  OPT[:output] = File.expand_path(OPT[:output] || OPT[:user]&.dir || "~")
+end
+
+# chroot
+
+def bootstrap
+  copy_xbps_keys
+  return if File.executable?(chroot_path("bin/bash"))
+
+  msg("bootstrapping chroot at #{OPT[:chroot]}")
+  system("xbps-install", "-Syu", "-R", VOID_REPO, "-r", OPT[:chroot], "base-voidstrap")
+  die("bootstrap failed") unless File.executable?(chroot_path("bin/bash"))
+  copy_xbps_keys
+end
+
+def copy_xbps_keys
+  keys = chroot_path("var/db/xbps/keys")
+  FileUtils.mkdir_p(keys)
+  FileUtils.cp(Dir.glob("/var/db/xbps/keys/*"), keys)
+  FileUtils.mkdir_p(chroot_path("etc"))
+  FileUtils.cp("/etc/resolv.conf", chroot_path("etc/resolv.conf"))
+end
+
+def mount_vfs
+  VFS.each do |name, opts|
+    dst = chroot_path(name)
+    FileUtils.mkdir_p(dst)
+    next if system("mountpoint", "-q", dst, out: File::NULL, err: File::NULL)
+
+    system("mount", *opts, dst)
+  end
+end
+
+def unmount_vfs
+  VFS.keys.reverse_each { system("umount", chroot_path(it), err: File::NULL) }
+end
+
+def install_deps
+  chroot_sh("installing build dependencies", <<~SH)
+    xbps-install -Syu
+    xbps-install -Sy #{DEPS.join(" ")}
+  SH
+end
+
+# essentia
+
+def build_essentia
+  stage_essentia
+  FileUtils.rm_rf(staging)
+
+  chroot_sh("building essentia (this takes a while)", <<~SH)
+    export HOME=/tmp
+    cd /src/essentia
+    python3 waf configure --prefix=/usr
+    python3 waf -j$(nproc)
+    python3 waf install --destdir=/staging
+  SH
+
+  lib = File.join(staging, "usr/lib/libessentia.so")
+  die("libessentia.so not found at #{lib}") unless File.file?(lib)
+
+  # essentia.pc lists only per-subdirectory include paths, so the
+  # `#include <essentia/...>` the essentia-rs bridge emits does not resolve.
+  rewrite(
+    File.join(staging, "usr/lib/pkgconfig/essentia.pc"),
+    "Cflags: -I${includedir}/essentia",
+    "Cflags: -I${includedir} -I${includedir}/essentia",
+    "patched essentia.pc"
+  )
+
+  package_essentia if OPT[:essentia_only]
+
+  chroot_sh("installing essentia into the chroot", <<~SH)
+    tar -C /staging -cf - . | tar -C / -xf -
+    ldconfig
+    echo "essentia $(pkg-config --modversion essentia) ok"
+  SH
+end
+
+# On the host, so the chroot needs neither curl nor ca-certificates.
+def stage_essentia
+  FileUtils.rm_rf(essentia_root)
+  FileUtils.mkdir_p(File.dirname(essentia_root))
+
+  if OPT[:essentia_src]
+    msg("copying #{OPT[:essentia_src]}")
+    FileUtils.cp_r(OPT[:essentia_src], essentia_root, preserve: true)
+    return
+  end
+
+  tarball = chroot_path("tmp/essentia.tar.gz")
+  FileUtils.mkdir_p(File.dirname(tarball))
+  sh("curl", "-fsSL", "-o", tarball, "https://github.com/MTG/essentia/archive/#{OPT[:rev]}.tar.gz")
+  FileUtils.mkdir_p(essentia_root)
+  sh("tar", "xzf", tarball, "-C", essentia_root, "--strip-components=1")
+  FileUtils.rm_f(tarball)
+end
+
+def package_essentia
+  out = File.join(OPT[:output], "essentia-#{OPT[:rev][0, 12]}-#{Etc.uname[:machine]}.tar.gz")
+  sh("tar", "czf", out, "-C", staging, ".")
+  owned_by_user(out)
+  msg("tarball: #{out} (install with: tar xzf #{out} -C /)")
+end
+
+# virittaa
+
+# Re-staged from scratch every run; target/ is carried across so rebuilds in
+# a persistent chroot stay incremental.
+def stage_virittaa
+  target = File.join(build_root, "target")
+  cache = chroot_path("src/target-cache")
+  if File.directory?(target)
+    FileUtils.rm_rf(cache)
+    FileUtils.mv(target, cache)
+  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(OPT[:source], build_root, items: SOURCE_ITEMS)
+
+  essentia_rs = OPT[:essentia_rs] || clone_essentia_rs
+  msg("staging essentia-rs from #{essentia_rs}")
+  copy_tree(essentia_rs, File.join(build_root, "vendor-essentia-rs"))
+
+  # The chroot has no ssh access to the remote.
+  rewrite(
+    File.join(build_root, "Cargo.toml"),
+    %(essentia = { git = "#{ESSENTIA_RS_URL}" }),
+    %(essentia = { path = "vendor-essentia-rs/essentia" }),
+    "pointed essentia at the staged checkout"
+  )
+  unpin_lock
+
+  # essentia_sys appends "eigen3" to every include path eigen3.pc reports,
+  # but Void's already ends in /eigen3. Same fix the flake applies.
+  rewrite(
+    File.join(build_root, "vendor-essentia-rs/essentia_sys/build.rs"),
+    'if library.name == "eigen3" {',
+    'if library.name == "eigen3" && !include_path.join("Eigen").is_dir() {',
+    "patched essentia_sys eigen include path"
+  )
+end
+
+# Yields a path-resolved lock, matching the rewrite above, so the build stays
+# --locked.
+def unpin_lock
+  lock = File.join(build_root, "Cargo.lock")
+  text = File.read(lock)
+  unpinned = text.gsub(/^source = "git\+#{Regexp.escape(ESSENTIA_RS_URL)}.*\n/o, "")
+  return if unpinned == text
+
+  File.write(lock, unpinned)
+  msg("unpinned essentia-rs git source in Cargo.lock")
+end
+
+# On the host, so the chroot needs no ssh setup. Kept beside the build root
+# rather than inside it, since that is wiped every run.
+def clone_essentia_rs
+  checkout = chroot_path("src/essentia-rs")
+  if File.directory?(File.join(checkout, ".git"))
+    msg("updating essentia-rs")
+    sh("git", "-C", checkout, "fetch", "--depth", "1", "origin", "HEAD")
+    sh("git", "-C", checkout, "reset", "--hard", "FETCH_HEAD")
+  else
+    FileUtils.rm_rf(checkout)
+    FileUtils.mkdir_p(File.dirname(checkout))
+    sh("git", "clone", "--depth", "1", ESSENTIA_RS_URL, checkout)
+  end
+  checkout
+end
+
+def build_virittaa
+  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
+
+# $ORIGIN resolves against the real path of the binary, so the tree survives
+# being moved or symlinked into ~/.local/bin.
+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 { it[/^\s*(\S+)\s+=>\s+not found/, 1] }
+  die("unresolved libraries: #{missing.join(", ")}") unless missing.empty?
+
+  libs = ldd.lines
+    .filter_map { it[%r{=>\s+(/\S+)}, 1] }
+    .uniq
+    .reject { SYSTEM_LIBS.include?(File.basename(it)) }
+    .map { chroot_path(it.delete_prefix("/")) }
+    .select { File.file?(it) }
+  die("ldd resolved no libraries") if libs.empty?
+
+  FileUtils.cp(libs, File.join(dist, "lib"))
+  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
+  version = File.read(File.join(OPT[:source], "Cargo.toml"))[/^version\s*=\s*"([^"]+)"/, 1] || "0"
+  tarball = File.join(OPT[:output], "virittaa-#{version}-#{Etc.uname[:machine]}.tar.gz")
+  sh("tar", "czf", tarball, "-C", File.dirname(dist), "virittaa")
+  owned_by_user(tarball)
+  msg("tarball: #{tarball}")
+  tarball
+end
+
+def install_local(tarball)
+  prefix = File.join(OPT[:user].dir, ".local")
+  target = File.join(prefix, "lib/virittaa")
+  link = File.join(prefix, "bin/virittaa")
+
+  FileUtils.mkdir_p([File.dirname(target), File.dirname(link)])
+  FileUtils.rm_rf(target)
+  sh("tar", "xzf", tarball, "-C", File.dirname(target))
+  FileUtils.rm_f(link)
+  FileUtils.ln_s(File.join(target, "bin/virittaa"), link)
+  owned_by_user(target, recursive: true)
+  owned_by_user(link)
+
+  out, _err, status = Open3.capture3(link, "--help")
+  die("installed binary does not run") unless status.success?
+  msg(out.lines.first.to_s.strip)
+  msg("installed: #{link} (add ~/.local/bin to PATH if it is not there already)")
+end
+
+# main
+
+parse_args(ARGV)
+validate
+
+begin
+  bootstrap
+  mount_vfs
+  install_deps
+  build_essentia
+
+  unless OPT[:essentia_only]
     stage_virittaa
     build_virittaa
     bundle
-    package
-    install_local if @install
-  ensure
-    unmount_vfs
-    if @delete
-      msg("removing #{@chroot}")
-      FileUtils.rm_rf(@chroot)
-    end
+    tarball = package
+    install_local(tarball) if OPT[:install]
   end
-
-  private
-
-  def option_parser
-    OptionParser.new do |o|
-      o.banner = "Usage: #{File.basename($PROGRAM_NAME)} [options]"
-      o.on("-c", "--chroot DIR", "chroot directory (default: #{@chroot})") { @chroot = it }
-      o.on("-s", "--source DIR", "virittaa source (default: current directory)") { @source = it }
-      o.on("-e", "--essentia-rs DIR", "local essentia-rs checkout (default: clone #{ESSENTIA_RS_URL})") { @essentia_rs = it }
-      o.on("-o", "--output DIR", "tarball output directory (default: invoking user's home)") { @output = it }
-      o.on("-r", "--rev REV", "essentia git revision (default: #{ESSENTIA_REV[0, 12]})") { @rev = it }
-      o.on("--essentia-src DIR", "build this essentia tree instead of fetching REV") { @essentia_src = it }
-      o.on("--essentia-only", "build libessentia, emit a tarball, and stop") { @essentia_only = true }
-      o.on("-n", "--no-install", "build only, do not install into ~/.local") { @install = false }
-      o.on("-d", "--delete", "delete the chroot when done (default: keep)") { @delete = true }
-      o.on("-q", "--quiet", "only report errors") { @quiet = true }
-      o.on("-h", "--help", "show this message") do
-        puts(o)
-        exit
-      end
-    end
+ensure
+  unmount_vfs
+  if OPT[:delete]
+    msg("removing #{OPT[:chroot]}")
+    FileUtils.rm_rf(OPT[:chroot])
   end
-
-  def validate
-    die("must run as root") unless Process.uid.zero?
-
-    @source = File.expand_path(@source)
-
-    unless @essentia_only
-      die("no Cargo.toml in #{@source}") unless File.file?(File.join(@source, "Cargo.toml"))
-      if @essentia_rs
-        @essentia_rs = File.expand_path(@essentia_rs)
-        unless File.file?(File.join(@essentia_rs, "essentia_sys", "build.rs"))
-          die("#{@essentia_rs} does not look like an essentia-rs checkout")
-        end
-      end
-    end
-
-    if @essentia_src
-      @essentia_src = File.expand_path(@essentia_src)
-      die("#{@essentia_src} does not look like essentia") unless File.directory?(File.join(@essentia_src, "src/essentia"))
-    end
-
-    invoking_user = ENV["SUDO_USER"] || ENV["DOAS_USER"]
-    if invoking_user
-      @user = begin
-        Etc.getpwnam(invoking_user)
-      rescue ArgumentError
-        die("cannot resolve user #{invoking_user}")
-      end
-    end
-    # Nothing to install into without an invoking user; root's home is not it.
-    @install = false if @user.nil?
-    @output = File.expand_path(@output || @user&.dir || "~")
-
-    @essentia_root = File.join(@chroot, "src/essentia")
-    @staging = File.join(@chroot, "staging")
-    @build_root = File.join(@chroot, "src/virittaa")
-    @dist = File.join(@chroot, "dist/virittaa")
-    @binary = File.join(@build_root, "target/release/virittaa")
-  end
-
-  # --- plumbing -------------------------------------------------------------
-
-  def msg(text)
-    puts(text) unless @quiet
-  end
-
-  def die(text)
-    warn("#{File.basename($PROGRAM_NAME)}: #{text}")
-    exit(1)
-  end
-
-  def sh(*cmd)
-    msg(cmd.join(" "))
-    _out, err, status = Open3.capture3(*cmd)
-    die("#{cmd.first} failed: #{err.strip}") unless status.success?
-  end
-
-  # Via a file rather than a quoted -c string, so snippets can use quotes,
-  # $(...) and newlines freely.
-  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(*cmd)
-    out, _err, status = Open3.capture3("chroot", @chroot, *cmd)
-    die("#{cmd.join(" ")} failed in chroot") unless status.success?
-    out
-  end
-
-  # A moved pattern is fatal: skipping it yields a build that breaks much later.
-  def rewrite(path, from, to, label)
-    die("missing #{path}") unless File.file?(path)
-
-    text = File.read(path)
-    return if text.include?(to)
-    die("#{path}: pattern not found: #{from}") unless text.include?(from)
-
-    File.write(path, text.sub(from, to))
-    msg(label)
-  end
-
-  # `items` if given, otherwise everything but build output and git data.
-  def copy_tree(src, dst, items: nil)
-    FileUtils.mkdir_p(dst)
-    entries = items || (Dir.children(src) - ["target", ".git"])
-    entries.each do |item|
-      path = File.join(src, item)
-      FileUtils.cp_r(path, dst, preserve: true) if File.exist?(path)
-    end
-  end
-
-  def owned_by_user(path, recursive: false)
-    return unless @user
-
-    if recursive
-      FileUtils.chown_R(@user.uid, @user.gid, path)
-    else
-      File.lchown(@user.uid, @user.gid, path)
-    end
-  rescue SystemCallError => e
-    msg("chown #{path} failed: #{e}")
-  end
-
-  # --- chroot ---------------------------------------------------------------
-
-  def bootstrap
-    copy_xbps_keys
-    return if File.executable?(File.join(@chroot, "bin/bash"))
-
-    msg("bootstrapping chroot at #{@chroot}")
-    system("xbps-install", "-Syu", "-R", VOID_REPO, "-r", @chroot, "base-voidstrap")
-    die("bootstrap failed") unless File.executable?(File.join(@chroot, "bin/bash"))
-    copy_xbps_keys
-  end
-
-  def copy_xbps_keys
-    keys = File.join(@chroot, "var/db/xbps/keys")
-    FileUtils.mkdir_p(keys)
-    FileUtils.cp(Dir.glob("/var/db/xbps/keys/*"), keys)
-    FileUtils.mkdir_p(File.join(@chroot, "etc"))
-    FileUtils.cp("/etc/resolv.conf", File.join(@chroot, "etc/resolv.conf"))
-  end
-
-  def mount_vfs
-    VFS.each do |name, opts|
-      dst = File.join(@chroot, name)
-      FileUtils.mkdir_p(dst)
-      next if system("mountpoint", "-q", dst, out: File::NULL, err: File::NULL)
-
-      system("mount", *opts, dst)
-    end
-  end
-
-  def unmount_vfs
-    VFS.keys.reverse_each { system("umount", File.join(@chroot, it), err: File::NULL) }
-  end
-
-  def install_deps
-    chroot_sh("installing build dependencies", <<~SH)
-      xbps-install -Syu
-      xbps-install -Sy #{DEPS.join(" ")}
-    SH
-  end
-
-  # --- essentia -------------------------------------------------------------
-
-  def build_essentia
-    stage_essentia
-    FileUtils.rm_rf(@staging)
-
-    chroot_sh("building essentia (this takes a while)", <<~SH)
-      export HOME=/tmp
-      cd /src/essentia
-      python3 waf configure --prefix=/usr
-      python3 waf -j$(nproc)
-      python3 waf install --destdir=/staging
-    SH
-
-    lib = File.join(@staging, "usr/lib/libessentia.so")
-    die("libessentia.so not found at #{lib}") unless File.file?(lib)
-
-    # essentia.pc lists only per-subdirectory include paths, so the
-    # `#include <essentia/...>` the essentia-rs bridge emits does not resolve.
-    rewrite(
-      File.join(@staging, "usr/lib/pkgconfig/essentia.pc"),
-      "Cflags: -I${includedir}/essentia",
-      "Cflags: -I${includedir} -I${includedir}/essentia",
-      "patched essentia.pc"
-    )
-
-    package_essentia if @essentia_only
-
-    chroot_sh("installing essentia into the chroot", <<~SH)
-      tar -C /staging -cf - . | tar -C / -xf -
-      ldconfig
-      echo "essentia $(pkg-config --modversion essentia) ok"
-    SH
-  end
-
-  # On the host, so the chroot needs neither curl nor ca-certificates.
-  def stage_essentia
-    FileUtils.rm_rf(@essentia_root)
-    FileUtils.mkdir_p(File.dirname(@essentia_root))
-
-    if @essentia_src
-      msg("copying #{@essentia_src}")
-      FileUtils.cp_r(@essentia_src, @essentia_root, preserve: true)
-      return
-    end
-
-    tarball = File.join(@chroot, "tmp/essentia.tar.gz")
-    FileUtils.mkdir_p(File.dirname(tarball))
-    sh("curl", "-fsSL", "-o", tarball, "https://github.com/MTG/essentia/archive/#{@rev}.tar.gz")
-    FileUtils.mkdir_p(@essentia_root)
-    sh("tar", "xzf", tarball, "-C", @essentia_root, "--strip-components=1")
-    FileUtils.rm_f(tarball)
-  end
-
-  def package_essentia
-    out = File.join(@output, "essentia-#{@rev[0, 12]}-#{Etc.uname[:machine]}.tar.gz")
-    sh("tar", "czf", out, "-C", @staging, ".")
-    owned_by_user(out)
-    msg("tarball: #{out} (install with: tar xzf #{out} -C /)")
-  end
-
-  # --- virittaa -------------------------------------------------------------
-
-  # Re-staged from scratch every run; target/ is carried across so rebuilds in
-  # a persistent chroot stay incremental.
-  def stage_virittaa
-    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)
-    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(@source, @build_root, items: SOURCE_ITEMS)
-
-    essentia_rs = @essentia_rs || clone_essentia_rs
-    msg("staging essentia-rs from #{essentia_rs}")
-    copy_tree(essentia_rs, File.join(@build_root, "vendor-essentia-rs"))
-
-    # The chroot has no ssh access to the remote.
-    rewrite(
-      File.join(@build_root, "Cargo.toml"),
-      %(essentia = { git = "#{ESSENTIA_RS_URL}" }),
-      %(essentia = { path = "vendor-essentia-rs/essentia" }),
-      "pointed essentia at the staged checkout"
-    )
-    unpin_lock
-
-    # essentia_sys appends "eigen3" to every include path eigen3.pc reports,
-    # but Void's already ends in /eigen3. Same fix the flake applies.
-    rewrite(
-      File.join(@build_root, "vendor-essentia-rs/essentia_sys/build.rs"),
-      'if library.name == "eigen3" {',
-      'if library.name == "eigen3" && !include_path.join("Eigen").is_dir() {',
-      "patched essentia_sys eigen include path"
-    )
-  end
-
-  # Yields a path-resolved lock, matching the rewrite above, so the build stays
-  # --locked.
-  def unpin_lock
-    lock = File.join(@build_root, "Cargo.lock")
-    text = File.read(lock)
-    unpinned = text.gsub(/^source = "git\+#{Regexp.escape(ESSENTIA_RS_URL)}.*\n/o, "")
-    return if unpinned == text
-
-    File.write(lock, unpinned)
-    msg("unpinned essentia-rs git source in Cargo.lock")
-  end
-
-  # On the host, so the chroot needs no ssh setup. Kept beside the build root,
-  # which is wiped every run, and refreshed in place when already present.
-  def clone_essentia_rs
-    checkout = File.join(@chroot, "src/essentia-rs")
-    if File.directory?(File.join(checkout, ".git"))
-      msg("updating essentia-rs")
-      sh("git", "-C", checkout, "fetch", "--depth", "1", "origin", "HEAD")
-      sh("git", "-C", checkout, "reset", "--hard", "FETCH_HEAD")
-    else
-      FileUtils.rm_rf(checkout)
-      FileUtils.mkdir_p(File.dirname(checkout))
-      sh("git", "clone", "--depth", "1", ESSENTIA_RS_URL, checkout)
-    end
-    checkout
-  end
-
-  def build_virittaa
-    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
-
-  # $ORIGIN resolves against the real path of the binary, so the tree survives
-  # being moved or symlinked into ~/.local/bin.
-  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 { it[/^\s*(\S+)\s+=>\s+not found/, 1] }
-    die("unresolved libraries: #{missing.join(", ")}") unless missing.empty?
-
-    libs = ldd.lines
-      .filter_map { it[%r{=>\s+(/\S+)}, 1] }
-      .uniq
-      .reject { SYSTEM_LIBS.include?(File.basename(it)) }
-      .map { File.join(@chroot, it.delete_prefix("/")) }
-      .select { File.file?(it) }
-    die("ldd resolved no libraries") if libs.empty?
-
-    FileUtils.cp(libs, File.join(@dist, "lib"))
-    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
-    version = File.read(File.join(@source, "Cargo.toml"))[/^version\s*=\s*"([^"]+)"/, 1] || "0"
-    @tarball = File.join(@output, "virittaa-#{version}-#{Etc.uname[:machine]}.tar.gz")
-    sh("tar", "czf", @tarball, "-C", File.dirname(@dist), "virittaa")
-    owned_by_user(@tarball)
-    msg("tarball: #{@tarball}")
-  end
-
-  # Running it outside the chroot is what the $ORIGIN rpath is for.
-  def install_local
-    prefix = File.join(@user.dir, ".local")
-    target = File.join(prefix, "lib/virittaa")
-    link = File.join(prefix, "bin/virittaa")
-
-    FileUtils.mkdir_p([File.dirname(target), File.dirname(link)])
-    FileUtils.rm_rf(target)
-    sh("tar", "xzf", @tarball, "-C", File.dirname(target))
-    FileUtils.rm_f(link)
-    FileUtils.ln_s(File.join(target, "bin/virittaa"), link)
-    owned_by_user(target, recursive: true)
-    owned_by_user(link)
-
-    out, _err, status = Open3.capture3(link, "--help")
-    die("installed binary does not run") unless status.success?
-    msg(out.lines.first.to_s.strip)
-    msg("installed: #{link} (add ~/.local/bin to PATH if it is not there already)")
-  end
 end
-
-Build.new(ARGV).run
blob - 7a6afa54673ae435d010952f53e26ee3cb896004
blob + 85f4bd513178cad3183e1df4dda988e3968fa5ce
--- src/file_io.rs
+++ src/file_io.rs
@@ -163,6 +163,25 @@ pub fn write_metadata(path: &Path, bpm: f32, key: &str
     }
 }
 
+/// Lofty drops an item whose key has no mapping for the tag type, so every
+/// insert is checked. Only ID3v2 and MP4 have an integer BPM field; Vorbis
+/// comments have the decimal one.
+fn insert_bpm_and_key(tag: &mut Tag, bpm: f32, key: &str) -> Result<()> {
+    let tag_type = tag.tag_type();
+    let bpm_str = bpm.round().to_string();
+
+    anyhow::ensure!(
+        tag.insert_text(ItemKey::IntegerBpm, bpm_str.clone())
+            || tag.insert_text(ItemKey::Bpm, bpm_str),
+        "{tag_type:?} tags have no BPM field"
+    );
+    anyhow::ensure!(
+        tag.insert_text(ItemKey::InitialKey, key.to_string()),
+        "{tag_type:?} tags have no key field"
+    );
+    Ok(())
+}
+
 fn write_lofty(path: &Path, bpm: f32, key: &str) -> Result<()> {
     let mut tagged_file = Probe::open(path)?
         .options(ParseOptions::new().read_properties(false))
@@ -177,11 +196,7 @@ fn write_lofty(path: &Path, bpm: f32, key: &str) -> Re
             .expect("failed to create new tag")
     };
 
-    tag.insert_text(ItemKey::IntegerBpm, bpm.round().to_string());
-    tag.insert_text(ItemKey::InitialKey, key.to_string());
-    if let Some(tkey) = ItemKey::from_key(tag.tag_type(), "TKEY") {
-        tag.insert_text(tkey, key.to_string());
-    }
+    insert_bpm_and_key(tag, bpm, key)?;
 
     tagged_file.save_to_path(path, WriteOptions::default())?;
     Ok(())
@@ -338,6 +353,22 @@ mod tests {
     }
 
     #[test]
+    fn vorbis_comments_get_a_bpm_tag() {
+        let mut tag = Tag::new(lofty::tag::TagType::VorbisComments);
+        insert_bpm_and_key(&mut tag, 128.4, "Bb Minor").unwrap();
+        assert_eq!(tag.get_string(ItemKey::Bpm), Some("128"));
+        assert_eq!(tag.get_string(ItemKey::InitialKey), Some("Bb Minor"));
+    }
+
+    #[test]
+    fn id3v2_gets_an_integer_bpm_tag() {
+        let mut tag = Tag::new(lofty::tag::TagType::Id3v2);
+        insert_bpm_and_key(&mut tag, 96.0, "A Minor").unwrap();
+        assert_eq!(tag.get_string(ItemKey::IntegerBpm), Some("96"));
+        assert_eq!(tag.get_string(ItemKey::InitialKey), Some("A Minor"));
+    }
+
+    #[test]
     fn file_metadata_defaults_to_none() {
         let md = FileMetadata::default();
         assert!(md.artist.is_none());