#!/usr/bin/env bash # Build vocalinux__.deb and vocalinux-..rpm. # # Usage: build.sh [output-dir] # version release version, e.g. 0.18.0 or 0.19.0-beta # # CI and releases run this inside docker-build.sh's pinned Debian 12 image; # it works on any host with bash, python3 + pip, curl and tar. The packages # stay thin on purpose (#600): the interpreter, GTK, PyGObject and the gi # typelibs come from the distribution, and so does every Python dependency # the distros package. Only pywhispercpp and pynput are vendored — the first # is unpackaged on both distros and the second is missing from Fedora. set -euo pipefail REPO_ROOT="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../.." && pwd)" PINS="$REPO_ROOT/packaging/native/tool_checksums.txt" fail() { echo "FAIL: $*" >&2 exit 1 } WHEEL="$(readlink -f "${1:?usage: build.sh [outdir]}")" RAW_VERSION="${2:?usage: build.sh [outdir]}" OUTDIR="$(mkdir -p "${3:-dist}" && cd "${3:-dist}" && pwd)" [ -f "$WHEEL" ] || fail "wheel not found: $WHEEL" # Split "0.19.0-beta" into version + prerelease; nfpm renders the prerelease # as a "~beta" suffix, which orders before the stable release in both dpkg # and rpm. An empty prerelease expands to nothing. VERSION="${RAW_VERSION#v}" PRERELEASE="" case "$VERSION" in *-*) PRERELEASE="${VERSION#*-}" VERSION="${VERSION%%-*}" ;; esac case "$(uname -m)" in x86_64) NFPM_PIN="nfpm-x86_64"; NFPM_ARCH="amd64" ;; aarch64|arm64) NFPM_PIN="nfpm-aarch64"; NFPM_ARCH="arm64" ;; *) fail "unsupported architecture $(uname -m)" ;; esac TOOLS="$(mktemp -d)" trap 'rm -rf "$TOOLS"' EXIT fetch_pinned() { local name="$1" dest="$2" url sum sum="$(awk -v n="$name" '$1==n && $2=="sha256" {print $3}' "$PINS")" url="$(awk -v n="$name" '$1==n && $2=="sha256" {print $4}' "$PINS")" [ -n "$sum" ] && [ -n "$url" ] || fail "no sha256 pin for $name in $PINS" curl -fSL --retry 3 -o "$dest" "$url" echo "$sum $dest" | sha256sum -c - >/dev/null } # nfpm is a single static binary inside the release tarball; unpack just it. fetch_pinned "$NFPM_PIN" "$TOOLS/nfpm.tar.gz" tar -xzf "$TOOLS/nfpm.tar.gz" -C "$TOOLS" nfpm NFPM="$TOOLS/nfpm" PAYLOAD="$TOOLS/payload" DOC="$TOOLS/pkg-doc" install -d \ "$PAYLOAD/usr/lib/vocalinux/app" \ "$PAYLOAD/usr/lib/vocalinux/vendor" \ "$PAYLOAD/usr/bin" \ "$PAYLOAD/usr/share/applications" \ "$PAYLOAD/usr/share/icons/hicolor/scalable/apps" \ "$DOC" # --- application payload ------------------------------------------------ # The wheel is a zip; -m zipfile lays out vocalinux/ and its dist-info # exactly where pip --target would, without pip's EXTERNALLY-MANAGED refusal # on Debian 12. python3 -m zipfile -e "$WHEEL" "$PAYLOAD/usr/lib/vocalinux/app/" # --- vendored wheels ----------------------------------------------------- # Download each supported CPython's pywhispercpp wheel — it has no abi3 tag, # so there is one wheel per interpreter — plus the pure-Python pynput, per # the hashes pinned in requirements/runtime.txt, and merge them flat into # the vendor dir. The extension filenames carry their tag # (_pywhispercpp.cpython-311-*.so next to .cpython-313-*.so) so all four # coexist, and the flat layout reproduces what pip would place in # site-packages, which is what utils/pywhispercpp_loader.py scans for. # # VENDOR_PYS tracks requires-python in pyproject.toml and the CI matrix. VENDOR_PYS="3.11 3.12 3.13 3.14" PINS_VENDOR="$TOOLS/vendor-requirements.txt" awk '/^(pywhispercpp|pynput)==/{p=1} p{print; if ($0 !~ /\\$/) p=0}' \ "$REPO_ROOT/requirements/runtime.txt" > "$PINS_VENDOR" grep -q "^pywhispercpp==" "$PINS_VENDOR" || fail "pywhispercpp pin not found in requirements/runtime.txt" grep -q "^pynput==" "$PINS_VENDOR" || fail "pynput pin not found in requirements/runtime.txt" for PYV in $VENDOR_PYS; do mkdir -p "$TOOLS/wheels/$PYV" pip3 download --require-hashes --no-deps --only-binary=:all: \ --python-version "$PYV" -r "$PINS_VENDOR" -d "$TOOLS/wheels/$PYV" >/dev/null done for VENDOR_WHEEL in "$TOOLS"/wheels/*/*.whl; do python3 -m zipfile -e "$VENDOR_WHEEL" "$PAYLOAD/usr/lib/vocalinux/vendor/" done # --- portable libggml-cpu ------------------------------------------------- # The pinned pywhispercpp wheels bundle a libggml-cpu compiled for AVX2+FMA, # which dies with SIGILL at model load on pre-Haswell CPUs (#957). Rebuild # the ggml libraries from the same pinned sdist at the plain x86-64 baseline — # GGML_NATIVE=OFF alone still compiles -mavx2 -mfma, because ggml turns every # GGML_* SIMD option on while its native default is on — and swap them into # the vendored copies. libwhisper and the Python extension carry no generated # SIMD, so only the libggml set needs replacing. SDIST_DIR="$TOOLS/pywhispercpp-sdist" mkdir -p "$SDIST_DIR" pip3 download --require-hashes --no-deps --no-binary pywhispercpp \ -r "$PINS_VENDOR" -d "$SDIST_DIR" >/dev/null tar -xzf "$SDIST_DIR"/pywhispercpp-*.tar.gz -C "$SDIST_DIR" WHISPER_GGML="$(echo "$SDIST_DIR"/pywhispercpp-*/whisper.cpp/ggml)" [ -d "$WHISPER_GGML" ] || fail "whisper.cpp/ggml missing from pywhispercpp sdist" # ggml.pc.in is generated upstream and absent from the sdist; it only feeds # the standalone install rules this build never runs. touch "$WHISPER_GGML/ggml.pc.in" cmake -S "$WHISPER_GGML" -B "$TOOLS/ggml-build" \ -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON \ -DGGML_NATIVE=OFF -DGGML_SSE42=OFF -DGGML_AVX=OFF -DGGML_AVX2=OFF \ -DGGML_BMI2=OFF -DGGML_FMA=OFF -DGGML_F16C=OFF \ -DGGML_BUILD_TESTS=OFF -DGGML_BUILD_EXAMPLES=OFF cmake --build "$TOOLS/ggml-build" --target ggml-cpu -j"$(nproc)" BASELINE_CPU="$(find "$TOOLS/ggml-build" -name 'libggml-cpu.so.*' -type f | sort -V | tail -1)" [ -f "$BASELINE_CPU" ] || fail "baseline libggml-cpu build produced no library" # objdump, not a feature check: the SIGILL is ISA-level, so assert no 256-bit # or FMA instructions survive in the library that ships. No grep -q: under # pipefail an early exit SIGPIPEs objdump and the pipeline reports a miss. if objdump -d "$BASELINE_CPU" | grep -E 'ymm|vfmadd|vfnmadd|vfmsub|vzeroupper' >/dev/null; then fail "baseline libggml-cpu still carries AVX/FMA instructions" fi # The vendored wheel files carry auditwheel-mangled basenames # (libggml-cpu-.so.0.9.8) that the extension's NEEDED entries point at, # so overwrite the cpu library in place and keep its filename. Only cpu is # swapped: the wheel's other libggml files carry no generated SIMD, and the # mangled core lib doubles as the extension's __cxa_call_terminate provider — # replacing it with a locally built copy drops that export. swapped=0 while IFS= read -r vendored; do cp "$BASELINE_CPU" "$vendored" swapped=$((swapped + 1)) done < <(find "$PAYLOAD/usr/lib/vocalinux/vendor" -name 'libggml-cpu*.so*') [ "$swapped" -gt 0 ] || fail "found no vendored libggml-cpu to replace" # The baseline library's own NEEDED entries use the unmangled sonames # (libggml-base.so.0, libgomp.so.1), so alias the vendored copies — the same # upstream version, and base/gomp carry no generated SIMD anyway — under # those names inside pywhispercpp.libs. while IFS= read -r libs_dir; do vendored_base="$(find "$libs_dir" -name 'libggml-base*.so*' | head -1)" [ -n "$vendored_base" ] || fail "no vendored libggml-base in $libs_dir" cp "$vendored_base" "$libs_dir/libggml-base.so.0" vendored_gomp="$(find "$libs_dir" -name 'libgomp*.so*' | head -1)" [ -n "$vendored_gomp" ] || fail "no vendored libgomp in $libs_dir" cp "$vendored_gomp" "$libs_dir/libgomp.so.1" done < <(find "$PAYLOAD/usr/lib/vocalinux/vendor" -type d -name 'pywhispercpp.libs') # --- launchers ----------------------------------------------------------- # Same environment install.sh's wrapper establishes: the vendored # pywhispercpp.libs on LD_LIBRARY_PATH, user site-packages off so they cannot # shadow the distro modules, and sg input so evdev hotkeys work on Wayland # when the user is already in the input group but the session has not picked # up the membership yet. vocalinux-gui exists because the wheel declares a # gui-script of that name; on Linux it runs the same entry. write_launcher() { local path="$1" cat > "$path" <<'LAUNCHER_EOF' #!/bin/sh # Launcher for the distro-packaged Vocalinux. The vendored wheels (the # pywhispercpp/pynput copies Debian and Fedora do not ship) live under # /usr/lib/vocalinux/vendor with the app itself under .../app; the loader in # utils/pywhispercpp_loader.py finds the bundled .so files from sys.path. PYTHONNOUSERSITE=1 VOCALINUX_VENDOR="/usr/lib/vocalinux/vendor" if [ -d "$VOCALINUX_VENDOR/pywhispercpp.libs" ]; then LD_LIBRARY_PATH="$VOCALINUX_VENDOR/pywhispercpp.libs${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" export LD_LIBRARY_PATH fi export PYTHONNOUSERSITE PYTHONPATH="/usr/lib/vocalinux/app:$VOCALINUX_VENDOR${PYTHONPATH:+:$PYTHONPATH}" if grep -q "^input:.*\b$(whoami)\b" /etc/group 2>/dev/null \ && ! groups | grep -q "\binput\b" && command -v sg >/dev/null 2>&1; then # sg(1) takes a single command string, so each argument is re-quoted for # the inner shell; joining $* would re-split paths containing spaces. QUOTED_ARGS="" for arg in "$@"; do arg="$(printf %s "$arg" | sed "s/'/'\\\\''/g")" QUOTED_ARGS="$QUOTED_ARGS '$arg'" done exec sg input -c "/usr/bin/python3 -m vocalinux.main$QUOTED_ARGS" fi exec /usr/bin/python3 -m vocalinux.main "$@" LAUNCHER_EOF chmod 755 "$path" } write_launcher "$PAYLOAD/usr/bin/vocalinux" write_launcher "$PAYLOAD/usr/bin/vocalinux-gui" # --- desktop entry, icon, license ---------------------------------------- install -Dm644 "$REPO_ROOT/vocalinux.desktop" \ "$PAYLOAD/usr/share/applications/vocalinux.desktop" install -Dm644 "$REPO_ROOT/resources/icons/scalable/vocalinux.svg" \ "$PAYLOAD/usr/share/icons/hicolor/scalable/apps/vocalinux.svg" install -Dm644 "$REPO_ROOT/LICENSE" "$DOC/LICENSE" # --- package ------------------------------------------------------------- # nfpm resolves contents src paths relative to its working directory, so run # it from $TOOLS where ./payload and ./pkg-doc live. cd "$TOOLS" VOCA_ARCH="$NFPM_ARCH" VOCA_VERSION="$VERSION" VOCA_PRERELEASE="$PRERELEASE" \ "$NFPM" package --config "$REPO_ROOT/packaging/native/nfpm.yaml" \ --packager deb --target "$OUTDIR/" VOCA_ARCH="$NFPM_ARCH" VOCA_VERSION="$VERSION" VOCA_PRERELEASE="$PRERELEASE" \ "$NFPM" package --config "$REPO_ROOT/packaging/native/nfpm.yaml" \ --packager rpm --target "$OUTDIR/" ls -la "$OUTDIR"/*.deb "$OUTDIR"/*.rpm