#!/usr/bin/env bash # Producer: the single entry point for Producer. The first run bootstraps a # fully contained runtime under lib/ (managed Python 3.13, dependencies, # bundled FFmpeg, and later the AI environments and model weights); every run # then executes the program from that runtime. Nothing is installed outside # this directory: no PATH changes, no sudo, no system Python changes. # # Trust boundary for downloads: official PyPI (uv, application wheels) and # Astral's managed CPython distribution. AI setup additionally trusts # download.pytorch.org, the pinned DeepFilterNet3 model host, and Hugging Face # (ClearVoice MossFormer2_SE_48K weights), plus hash-checked Silero VAD # weights from its pinned GitHub release. Host package-manager settings # (extra indexes, targets, constraints, config files) cannot add to or # redirect these sources. # # First bootstrap needs an existing `uv`, or a Python 3 with venv support. # Later runs, including moving or renaming this whole directory, are handled # by the launcher itself; caches make the repair fast. set -euo pipefail SOURCE="${BASH_SOURCE[0]}" while [[ -L "$SOURCE" ]]; do DIR="$(CDPATH='' cd -- "$(dirname -- "$SOURCE")" && pwd)" SOURCE="$(readlink -- "$SOURCE")" [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE" done ROOT="$(CDPATH='' cd -- "$(dirname -- "$SOURCE")" && pwd)" LIB="$ROOT/lib" RUNTIME="$LIB/runtime" APP="$RUNTIME/app" PROJECT="$LIB/project" READY="$RUNTIME/.producer-ready" GUI_READY="$RUNTIME/.producer-gui-ready" # Bump when the bootstrap recipe or installed application contract changes. # Version 4 adds the optional GUI command; only GUI launches install its extra. RUNTIME_VERSION="4" usage() { cat <<'USAGE' Producer - local speech cleanup and podcast mastering. Usage: ./producer.sh COMMAND [options], or simply: ./producer.sh RECORDING.wav... RECORDING.wav... Master one or more WAV files ('process' is optional) process RECORDING.wav... The same, with the explicit command keyword preview RECORDING.wav Render loudness-matched profile previews config Print resolved settings (TOML, or JSON with --json) gui [--root PATH] [--port N] Open the optional local browser GUI (loopback only) doctor Check FFmpeg, data directory and CUDA driver setup --device cpu|cuda Install and self-test the AI backend (--denoiser clearvoice for the MossFormer2 backend) Outputs are written beside each input by default. Run './producer.sh COMMAND --help' for the options of a command. './producer.sh --rebuild COMMAND...' re-runs the bootstrap first, for example after changing the source code under lib/project. A rebuild waits for running producer jobs to finish. USAGE } log() { printf '[producer] %s\n' "$*" >&2; } fail() { printf '[producer] Error: %s\n' "$*" >&2; exit 1; } case "${1:-}" in -h|--help) usage; exit 0 ;; esac REBUILD=0 ARGS=() for arg in "$@"; do case "$arg" in --rebuild) REBUILD=1 ;; *) ARGS+=("$arg") ;; esac done # 'producer.sh --rebuild --help' asks for help, not a rebuild. if [[ ${#ARGS[@]} -eq 1 && ( "${ARGS[0]}" == "-h" || "${ARGS[0]}" == "--help" ) ]]; then usage exit 0 fi WANT_GUI=0 [[ "${ARGS[0]-}" == "gui" ]] && WANT_GUI=1 # A config.toml next to this script is loaded automatically for every # command that accepts --config, unless one is given explicitly. Named CLI # flags and --set still override values from the file. HAVE_CONFIG=0 for arg in ${ARGS[@]+"${ARGS[@]}"}; do case "$arg" in --config|--config=*) HAVE_CONFIG=1 ;; esac done if [[ $HAVE_CONFIG -eq 0 && -f "$ROOT/config.toml" ]]; then case "${ARGS[0]-}" in # After the command word: the CLI treats a leading non-command # argument as the implicit 'process' command and then as an input. process|preview|config|gui) ARGS=("${ARGS[0]}" "--config=$ROOT/config.toml" "${ARGS[@]:1}") ;; # These commands take no --config (or no arguments at all). doctor|setup|-h|--help|--version|"") ;; *) # Bare inputs or options: the CLI inserts 'process' first, so the # flag can go in front. ARGS=("--config=$ROOT/config.toml" ${ARGS[@]+"${ARGS[@]}"}) ;; esac fi mkdir -p -- "$RUNTIME/cache/pip" "$RUNTIME/cache/uv" "$RUNTIME/uv" "$RUNTIME/tmp" \ || fail "Cannot create the runtime directory under $LIB" # Contained environment: host Python settings and package-manager caches # cannot leak in, and nothing (temporary files included) is written outside # lib/runtime. Installation-target, index, and constraint overrides from the # host are dropped and package-manager configuration files are disabled, so # packages can only come from the explicitly passed trusted indexes and land # inside lib/runtime. The application reads PRODUCER_HOME for AI envs and # models. unset PYTHONPATH PYTHONHOME VIRTUAL_ENV \ PIP_TARGET PIP_PREFIX PIP_USER PIP_FIND_LINKS PIP_INDEX_URL PIP_EXTRA_INDEX_URL \ PIP_CONSTRAINT \ UV_INDEX_URL UV_DEFAULT_INDEX UV_EXTRA_INDEX_URL UV_INDEX UV_FIND_LINKS \ UV_CONSTRAINT export PYTHONNOUSERSITE=1 export PIP_CACHE_DIR="$RUNTIME/cache/pip" UV_CACHE_DIR="$RUNTIME/cache/uv" export PIP_CONFIG_FILE=/dev/null UV_NO_CONFIG=1 export UV_PYTHON_INSTALL_DIR="$RUNTIME/uv/python" XDG_CACHE_HOME="$RUNTIME/cache" export TMPDIR="$RUNTIME/tmp" PRODUCER_HOME="$RUNTIME" marker_current() { # The marker must name this exact path, and the interpreter must exist: # a failed or interrupted rebuild leaves neither, forcing a repair. [[ -f "$READY" ]] \ && [[ "$(cat -- "$READY" 2>/dev/null)" == "$RUNTIME v$RUNTIME_VERSION" ]] \ && [[ -x "$APP/bin/python" ]] \ && { [[ "$WANT_GUI" -eq 0 ]] || { [[ -f "$GUI_READY" ]] \ && [[ "$(cat -- "$GUI_READY" 2>/dev/null)" == "$RUNTIME v$RUNTIME_VERSION" ]]; }; } } bootstrap() { command -v flock >/dev/null 2>&1 \ || fail "The 'flock' utility (util-linux) is required but was not found." # Bootstrap lock (fd 9): serializes bootstrap across launches and covers # the whole destructive window below. It is released before the # application starts. exec 9>"$RUNTIME/bootstrap.lock" flock 9 if [[ "$REBUILD" -eq 0 ]] && marker_current; then exec 9>&- # Runtime lock (fd 8): held shared for the application's lifetime so # a later --rebuild cannot delete files this process runs from. exec 8>"$RUNTIME/app.lock" flock -s 8 return 0 fi # Exclusive runtime lock: waits out running jobs before anything is # deleted, so a rebuild can never clobber a live environment. exec 8>"$RUNTIME/app.lock" if ! flock -n 8; then log "Waiting for running producer jobs to finish before building the runtime" flock 8 fi # Invalidate the marker before any destructive work: if this bootstrap # fails or is interrupted, the next launch repairs instead of trusting # a half-built environment. rm -f -- "$READY" "$READY.tmp" "$GUI_READY" log "Preparing the contained runtime in lib/runtime (first run, or the directory moved)" local UV="" local err="" if [[ -x "$RUNTIME/bootstrap/bin/uv" ]] \ && "$RUNTIME/bootstrap/bin/uv" --version >/dev/null 2>&1; then UV="$RUNTIME/bootstrap/bin/uv" elif command -v uv >/dev/null 2>&1; then UV="$(command -v uv)" else command -v python3 >/dev/null 2>&1 \ || fail "First-run bootstrap needs 'uv' or Python 3 with venv support; neither was found." log "Bootstrapping uv from PyPI" rm -rf -- "$RUNTIME/bootstrap" python3 -m venv "$RUNTIME/bootstrap" \ || fail "python3 -m venv failed; install venv support with your system package manager and retry. Nothing was run with sudo." "$RUNTIME/bootstrap/bin/python" -m pip install --index-url https://pypi.org/simple 'uv==0.8.17' >/dev/null \ || fail "Downloading uv from PyPI failed; check network access." UV="$RUNTIME/bootstrap/bin/uv" fi # Managed-Python alias links are absolute; after a move they dangle while # the downloaded payload is still intact. Drop stale links so uv # re-resolves what is already on disk instead of re-downloading. find "$RUNTIME/uv" -maxdepth 1 -type l ! -exec test -e {} \; -delete 2>/dev/null || true rm -rf -- "$APP" # Python 3.13 for the application runtime: earlier musl builds from uv # misreport their extension suffix and cannot load musl-tagged wheels. # The AI worker keeps its own pinned Python 3.11 (glibc only). "$UV" venv --python 3.13 --managed-python --relocatable --allow-existing "$APP" \ || fail "Creating the managed Python 3.13 environment failed; check network access and free space." # The bundled FFmpeg wheel is preferred; where it does not exist (for # example musl systems), fall back to a system FFmpeg at runtime. local bundled="$PROJECT[bundled-ffmpeg]" core="$PROJECT" if [[ "$WANT_GUI" -eq 1 ]]; then bundled="$PROJECT[bundled-ffmpeg,gui]" core="$PROJECT[gui]" fi if ! err="$("$UV" pip install --python "$APP/bin/python" --index-url https://pypi.org/simple \ "$bundled" 2>&1 >/dev/null)"; then log "Bundled FFmpeg wheel unavailable; installing core dependencies (a system FFmpeg is then required)." if [[ -n "$err" ]]; then sed 's/^/[producer] /' <<<"$err" >&2 fi "$UV" pip install --python "$APP/bin/python" --index-url https://pypi.org/simple "$core" \ || fail "Installing the application dependencies failed; check network access and free space." fi "$APP/bin/python" -I -c "import producer" >/dev/null 2>&1 \ || fail "The application environment failed verification." if [[ "$WANT_GUI" -eq 1 ]]; then "$APP/bin/python" -I -c "import fastapi, uvicorn" >/dev/null 2>&1 \ || fail "The optional GUI environment failed verification." printf '%s v%s\n' "$RUNTIME" "$RUNTIME_VERSION" > "$GUI_READY" fi printf '%s v%s\n' "$RUNTIME" "$RUNTIME_VERSION" > "$READY.tmp" mv -f -- "$READY.tmp" "$READY" # Downgrade to the shared runtime lock for the application's lifetime. flock -s 8 exec 9>&- } bootstrap exec "$APP/bin/python" -I -m producer ${ARGS[@]+"${ARGS[@]}"}