Import the vocalinux source tree (tray app, Next.js site in web/, packaging, installers, docs and tests) on top of the Gitea template commit. The project README replaces the template README, and the template CI files (.gitea/workflows/ci.yml, .jscpd.json, CI.md) are kept.
221 lines
10 KiB
Bash
Executable File
221 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Build vocalinux_<version>_<arch>.deb and vocalinux-<version>.<arch>.rpm.
|
|
#
|
|
# Usage: build.sh <path-to-vocalinux-wheel> <version> [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 <wheel> <version> [outdir]}")"
|
|
RAW_VERSION="${2:?usage: build.sh <wheel> <version> [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-<hash>.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
|