commit f9a9892f265e00cf11716156b76d7a8cf8f5cdc8 from: mtmn date: Tue Jul 28 01:39:56 2026 UTC fix missing bpm tags 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 ` 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 ` 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());