commit - 51ec796bc07ae3316c9f9cbfc51308b778e47056
commit + f9a9892f265e00cf11716156b76d7a8cf8f5cdc8
blob - dd626ae9e583100ac7eb428504bfaf608c7072e5
blob + 54aacdf2f6fcd019df2da57d3db7c3be64289122
--- hack/void-build.rb
+++ hack/void-build.rb
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
}
}
+/// 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))
.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(())
}
#[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());