#!/usr/bin/env bash # tools/build_piper_worker.sh — compila el sintetizador aislado. s70. # # piper-tts es GPL-3.0-or-later y trae espeak-ng dentro. La GPL exige que todo # programa que ENLACE ese código sea también GPL, así que RadioCheck (cerrado) no # puede importarlo. Este script construye `piper-worker`: un ejecutable APARTE, # GPL, con el motor dentro, al que RadioCheck habla por entrada/salida estándar. # Es el mismo patrón que usamos con ffmpeg. # # El binario resultante se empaqueta como una herramienta más dentro del .app, y # su código fuente (tools/piper_worker/worker.py) se publica junto al producto. # # FUENTE CORRESPONDIENTE (GPLv3 §6: el código fuente completo, con «los scripts que # controlan» la compilación, accesible desde el mismo sitio que el binario). Revisión # del lote A, 23-sep-2026: # - Piper: el sdist de piper-tts en PyPI NO sirve de fuente completa: le faltan el # CMakeLists.txt y espeakbridge.c (la extensión en C que va dentro del wheel). Se # archiva el repositorio piper1-gpl en el commit de la etiqueta v # (PIPER_COMMIT), bajado con git: la identidad del commit verifica el árbol. Para # atarlo al paquete que de verdad instalamos, cada fichero del sdist (cuyo sha256 fija # el lock) tiene que ser idéntico al del commit. # - eSpeak NG: piper lo baja de GitHub al compilar (ExternalProject, GIT_TAG en su # CMakeLists). Se archiva ese commit (ESPEAK_COMMIT) igual, y se comprueba que el # CMakeLists del commit de piper fija exactamente ese. # - worker.py, esta receta, tools/_build_python.sh y el lock. # Todo se guarda en third_party/sources/ (persistente, gitignored) y se lista con su # sha256 en third_party/piper-worker/SOURCES-piper-worker.sha256. Receta, lock y lista # viajan DENTRO de la app (Resources/licenses); tools/publish_dmg.sh sube la lista entera # a https://dl.radiocheckapp.com/source// junto al DMG y falla si falta algo. # # Lote A de la auditoría (PORT-001, 23-sep-2026): se compila con el Python autónomo de # tools/_build_python.sh (minos 11) en su PROPIO venv (venv-piper.nosync), fijado por # tools/piper_worker/requirements.lock. Antes salía de venv.nosync (Python de Homebrew): # su carga útil llevaba 56 binarios que exigían macOS 26. Además el venv de la app # (venv-build.nosync) ya no contiene piper: el código GPL no está ni en el entorno que # construye la app cerrada. # # Uso: bash tools/build_piper_worker.sh # compila # bash tools/build_piper_worker.sh --lock # regenera tools/piper_worker/requirements.lock # bash tools/build_piper_worker.sh --sources # sólo baja y verifica las fuentes # Salida: third_party/piper-worker/{piper-worker, LICENSE.txt, worker.py.source, # build_piper_worker.sh, _build_python.sh, piper-worker-requirements.lock, # SOURCES-piper-worker.sha256} # third_party/sources/{piper1-gpl-.tar.gz, espeak-ng-.tar.gz, # + la receta} set -euo pipefail cd "$(dirname "$0")/.." REPO="$PWD" DEST="$REPO/third_party/piper-worker" SRC="$REPO/third_party/sources" LOCK="tools/piper_worker/requirements.lock" # piper1-gpl en la etiqueta v1.4.1 (= piper-tts==1.4.1 del lock), y el commit de eSpeak NG # que su CMakeLists compila dentro del wheel. Si cambia piper-tts en el lock, el script # se niega hasta que se actualicen los dos (git ls-remote ... refs/tags/v). PIPER_TAG_VERSION="1.4.1" PIPER_COMMIT="077c783a28a8eb31c269bf2fc79e1f834d265809" ESPEAK_COMMIT="212928b394a96e8fd2096616bfd54e17845c48f6" source tools/_build_python.sh if [[ "${1:-}" == "--lock" ]]; then uv pip compile tools/piper_worker/requirements.in --python-version 3.11 \ --python-platform aarch64-apple-darwin --generate-hashes -q -o "$LOCK" \ --custom-compile-command "bash tools/build_piper_worker.sh --lock" uvx --quiet pip-audit --strict --no-deps --disable-pip -r "$LOCK" echo "✓ $LOCK"; exit 0 fi PIPER_VERSION="$(sed -nE 's/^piper-tts==([^ ]+).*/\1/p' "$LOCK")" [ -n "$PIPER_VERSION" ] || { echo "❌ piper-tts no está en $LOCK"; exit 1; } [ "$PIPER_VERSION" = "$PIPER_TAG_VERSION" ] || { echo "❌ el lock pide piper-tts $PIPER_VERSION y PIPER_COMMIT es de v$PIPER_TAG_VERSION: actualízalo"; exit 1; } PIPER_SDIST="piper_tts-${PIPER_VERSION}.tar.gz" # sólo para verificar (third_party/.build) PIPER_TGZ="piper1-gpl-${PIPER_COMMIT}.tar.gz" ESPEAK_TGZ="espeak-ng-${ESPEAK_COMMIT}.tar.gz" # La receta, con el nombre con el que viaja en la app y se publica junto a las fuentes. RECIPE_FILES=(worker.py.source build_piper_worker.sh _build_python.sh piper-worker-requirements.lock) # git_tarball URL COMMIT NOMBRE — archiva el árbol de COMMIT como $SRC/NOMBRE-COMMIT.tar.gz. # git comprueba que los objetos recibidos son los de ese commit (su hash los identifica). git_tarball() { local url="$1" commit="$2" name="$3" g local out="$SRC/${name}-${commit}.tar.gz" [ -f "$out" ] && return 0 echo "→ $name ${commit:0:12} (git, commit verificado)" g="$(mktemp -d)" git -C "$g" init -q git -C "$g" fetch -q --depth 1 "$url" "$commit" [ "$(git -C "$g" rev-parse FETCH_HEAD)" = "$commit" ] || { echo "❌ $name: commit distinto"; exit 1; } # Un submódulo no vendría dentro del archivo: la fuente quedaría incompleta. if git -C "$g" ls-tree -r FETCH_HEAD | awk '$2=="commit"' | grep -q .; then echo "❌ $name ${commit} tiene submódulos: el tarball no sería la fuente completa"; exit 1 fi git -C "$g" archive --format=tar.gz --prefix="${name}-${commit}/" -o "$out.part" FETCH_HEAD mv "$out.part" "$out" rm -rf "$g" } # fuentes_exactas — baja (si no están ya en third_party/sources) y VERIFICA las fuentes # de lo que va dentro de piper-worker. fuentes_exactas() { mkdir -p "$SRC" "$REPO/third_party/.build" local sdist="$REPO/third_party/.build/$PIPER_SDIST" if [ ! -f "$sdist" ]; then echo "→ descargando $PIPER_SDIST (PyPI, sólo para verificar)" curl -fsSL -o "$sdist.part" "https://files.pythonhosted.org/packages/source/p/piper-tts/$PIPER_SDIST" mv "$sdist.part" "$sdist" fi # 1. El sdist es el MISMO que fija el lock: su sha256 está entre los hashes de piper-tts. local sha; sha="$(shasum -a 256 "$sdist" | cut -d' ' -f1)" sed -n '/^piper-tts==/,/# via/p' "$LOCK" | grep -q "sha256:$sha" \ || { echo "❌ $PIPER_SDIST ($sha) no es el que fija $LOCK"; exit 1; } git_tarball https://github.com/OHF-Voice/piper1-gpl.git "$PIPER_COMMIT" piper1-gpl git_tarball https://github.com/espeak-ng/espeak-ng.git "$ESPEAK_COMMIT" espeak-ng local t; t="$(mktemp -d)" tar -xzf "$sdist" -C "$t"; tar -xzf "$SRC/$PIPER_TGZ" -C "$t" # 2. El commit de piper ES la versión del lock: todo fichero del sdist bajo src/piper # está idéntico en el commit (el sdist no trae el C; el commit sí). local a="$t/piper_tts-${PIPER_VERSION}" b="$t/piper1-gpl-${PIPER_COMMIT}" n=0 f while IFS= read -r f; do cmp -s "$a/$f" "$b/$f" || { echo "❌ $f del sdist difiere del commit $PIPER_COMMIT"; rm -rf "$t"; exit 1; } n=$((n+1)) done < <(cd "$a" && find src/piper -type f) [ "$n" -gt 10 ] || { echo "❌ el sdist no trae src/piper ($n ficheros)"; rm -rf "$t"; exit 1; } # 3. ...y ese commit compila eSpeak NG exactamente en el commit que archivamos. grep -q "GIT_TAG $ESPEAK_COMMIT" "$b/CMakeLists.txt" \ || { echo "❌ piper $PIPER_COMMIT no fija eSpeak NG en $ESPEAK_COMMIT: actualiza ESPEAK_COMMIT"; rm -rf "$t"; exit 1; } [ -f "$b/src/piper/espeakbridge.c" ] || { echo "❌ falta espeakbridge.c en $PIPER_TGZ"; rm -rf "$t"; exit 1; } rm -rf "$t" echo " ✓ fuentes de piper-worker verificadas: piper1-gpl ${PIPER_COMMIT:0:12} = sdist del lock ($n ficheros), eSpeak NG ${ESPEAK_COMMIT:0:12}" } # receta_a DIR — worker.py, las dos recetas y el lock, con su nombre publicado. receta_a() { install -m 644 tools/piper_worker/worker.py "$1/worker.py.source" install -m 644 tools/build_piper_worker.sh "$1/build_piper_worker.sh" install -m 644 tools/_build_python.sh "$1/_build_python.sh" install -m 644 "$LOCK" "$1/piper-worker-requirements.lock" } if [[ "${1:-}" == "--sources" ]]; then fuentes_exactas; exit 0; fi echo "── piper-worker · sintetizador GPL aislado (Python ${BUILD_PY_VERSION} autónomo)" fuentes_exactas ensure_build_venv venv-piper.nosync "$LOCK" PYW=venv-piper.nosync/bin/python $PYW -c "import piper" 2>/dev/null || { echo "❌ piper-tts no está en venv-piper.nosync"; exit 1; } rm -rf "$DEST" build/piper-worker # `piper` incluye su propio módulo de ENTRENAMIENTO, que depende de torch: sin estas # exclusiones el ejecutable pasa de 25 a 192 MB con código que jamás se ejecuta aquí. # Este programa sólo sintetiza: carga un .onnx y devuelve muestras. venv-piper.nosync/bin/pyinstaller \ --name piper-worker \ --onefile \ --noconfirm \ --log-level WARN \ --distpath "$DEST" \ --workpath build/piper-worker \ --specpath build/piper-worker \ --collect-all piper \ --hidden-import piper \ --exclude-module piper.train \ --exclude-module torch --exclude-module torchaudio --exclude-module torchgen \ --exclude-module pytorch_lightning --exclude-module lightning \ --exclude-module numba --exclude-module llvmlite \ --exclude-module scipy --exclude-module pandas --exclude-module matplotlib \ --exclude-module PIL --exclude-module Pillow --exclude-module tkinter \ tools/piper_worker/worker.py BIN="$DEST/piper-worker" [ -x "$BIN" ] || { echo "❌ no se produjo $BIN"; exit 1; } # Prueba real: cargar un modelo y sintetizar una frase. MODELO="$REPO/assets/voices/en_GB-alan-medium.onnx" if [ -f "$MODELO" ]; then echo "→ prueba de síntesis" RES=$(printf '{"text":"Radio check.","length_scale":0.65,"noise_scale":0.9}\n' \ | "$BIN" "$MODELO" 2>/dev/null | head -c 200 | tr -d '\0' | head -2 | tail -1) echo "$RES" | grep -q '"ok": *true' \ && echo "✅ el sintetizador aislado responde" \ || { echo "❌ el sintetizador no devolvió audio: $RES"; exit 1; } fi # Fuente correspondiente: dentro de la app (Resources/licenses) y en third_party/sources # para que tools/publish_dmg.sh la suba junto al DMG. receta_a "$DEST"; receta_a "$SRC" ( cd "$SRC" && shasum -a 256 "$PIPER_TGZ" "$ESPEAK_TGZ" "${RECIPE_FILES[@]}" ) \ > "$DEST/SOURCES-piper-worker.sha256" cat > "$DEST/LICENSE.txt" </${PIPER_TGZ} Piper (piper1-gpl) at commit ${PIPER_COMMIT}, tag v${PIPER_VERSION}: the source of the piper-tts ${PIPER_VERSION} wheel inside https://dl.radiocheckapp.com/source//${ESPEAK_TGZ} eSpeak NG at commit ${ESPEAK_COMMIT}, the commit piper-tts ${PIPER_VERSION} builds into its wheel SOURCE_CODE.txt, next to this file, gives the full address of every file for this copy of RadioCheck; SOURCES-piper-worker.sha256 lists each one with its sha256. If a link ever stops working, write to hello@radiocheckapp.com and we will send the same files. Upstream projects: https://github.com/OHF-Voice/piper1-gpl https://github.com/espeak-ng/espeak-ng Components packaged inside piper-worker (exact versions in piper-worker-requirements.lock): piper-tts, eSpeak NG .................................. GPL-3.0-or-later onnxruntime ........................................... MIT NumPy ................................................. BSD-3-Clause CPython (python-build-standalone) and OpenSSL ......... PSF-2.0 / Apache-2.0 PyInstaller bootloader ................................ GPL-2.0-or-later with the bootloader exception --- piper-worker es software libre bajo la GPL de GNU, versión 3 o posterior. Su código fuente completo (arriba) viaja junto a este fichero y está publicado junto a la descarga de RadioCheck de esta versión, en https://dl.radiocheckapp.com/source//. EOF echo "── listo: $BIN ($(du -h "$BIN" | cut -f1))"