#!/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 [--port N] Open the local browser GUI (loopback only; upload any WAV from the browser) 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; } # uv writes the base interpreter's absolute path into every venv it creates # (the 'home' line in pyvenv.cfg and the bin/python* links). This directory # is shared between environments that mount it at different paths (a # container sees /workspace, the host does not), so re-point all of them to # the current path on every launch. This is a tiny local rewrite, never a # rebuild, and it self-heals in whichever environment launched last. repoint_runtime() { local cfg venv home tail tmp line link target for cfg in "$RUNTIME"/*/pyvenv.cfg; do [[ -f "$cfg" ]] || continue venv="$(dirname -- "$cfg")" home="$(sed -n 's/^home = //p' "$cfg" | head -1)" # Only managed Pythons inside this runtime can be re-pointed; the # tail after '/lib/runtime/' is the stable, path-independent part. if [[ "$home" == */lib/runtime/uv/python/* \ && "$home" != "$RUNTIME"/* ]]; then tail="${home#*/lib/runtime/}" if [[ -d "$RUNTIME/$tail" ]]; then tmp="$cfg.tmp.$$" { while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" == "home = "* ]]; then printf 'home = %s\n' "$RUNTIME/$tail" else printf '%s\n' "$line" fi done < "$cfg"; } > "$tmp" && mv -f -- "$tmp" "$cfg" fi fi for link in "$venv"/bin/python "$venv"/bin/python3 "$venv"/bin/python3.*; do [[ -L "$link" ]] || continue target="$(readlink -- "$link")" case "$target" in */lib/runtime/uv/python/*) # Map stale mounts of this same directory onto the current # path, then make the link relative. Skip if the target is # missing (a broken install is the bootstrap's problem). tail="${target#*/lib/runtime/}" if [[ -e "$RUNTIME/$tail" ]]; then ln -sfn -- "$(realpath -m --relative-to="$venv/bin" -- "$RUNTIME/$tail")" "$link" fi ;; esac done done } # Print the PIDs of processes holding an open descriptor for the given file # (a leading space each, e.g. " 1234 5678"). A plain /proc scan avoids a # dependency on fuser or lsof, which may not be installed. Only processes # visible in the current PID namespace can be found. lock_holders() { local file="$1" id p fd target base id="$(stat -c '%d:%i' -- "$file" 2>/dev/null)" || return 0 base="${file##*/}" for p in /proc/[0-9]*; do [[ "${p#/proc/}" == "$$" ]] && continue for fd in "$p"/fd/*; do target="$(readlink -- "$fd" 2>/dev/null)" || continue [[ "$target" == *"$base" ]] || continue if [[ "$(stat -c '%d:%i' -L -- "$fd" 2>/dev/null)" == "$id" ]]; then printf ' %s' "${p#/proc/}" fi done done return 0 } 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() { # Only the version suffix decides; the recorded path is informational. # The runtime is shared between environments that mount this directory # at different paths (a container sees /workspace, the host does not), # and a broken move is caught anyway by the interpreter check below. # A failed or interrupted rebuild leaves no valid marker, forcing a # repair. [[ -f "$READY" ]] \ && [[ "$(cat -- "$READY" 2>/dev/null)" == *" v$RUNTIME_VERSION" ]] \ && [[ -x "$APP/bin/python" ]] \ && { [[ "$WANT_GUI" -eq 0 ]] || { [[ -f "$GUI_READY" ]] \ && [[ "$(cat -- "$GUI_READY" 2>/dev/null)" == *" 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" if ! flock -n 9; then # Never block silently: another launch is mid-bootstrap. local holders9="" holders9="$(lock_holders "$RUNTIME/bootstrap.lock")" if [[ -n "$holders9" ]]; then log "Waiting for another producer launch to finish preparing the runtime (held by PID(s):$holders9)" else log "Waiting for another producer launch to finish preparing the runtime" fi flock 9 fi if [[ "$REBUILD" -eq 0 ]] && marker_current; then repoint_runtime 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 # Point at the concrete holder: usually an earlier GUI or processing # job that is still running, not a leftover lock file (flock locks # disappear with their process). local holders="" holders="$(lock_holders "$RUNTIME/app.lock")" if [[ -n "${holders//[[:space:]]/}" ]]; then log "Waiting for running producer jobs to finish before building the runtime (held by PID(s):$holders)" log "Stop them first, e.g. 'kill$holders' (a running 'gui' server counts as a job)." else log "Waiting for running producer jobs to finish before building the runtime" fi 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" repoint_runtime # 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[@]}"}