aboutsummaryrefslogtreecommitdiff
path: root/producer.sh
blob: 3c792f326b4fc271a490e09e6f5ce3e5be7dc64e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
#!/usr/bin/env bash
# Producer: the single entry point for VoiceForge. 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 and the pinned DeepFilterNet3 model host. 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"
# Bump when the bootstrap recipe changes (python version, install steps...).
RUNTIME_VERSION="2"

usage() {
    cat <<'USAGE'
Producer - local speech cleanup and podcast mastering (VoiceForge).

Usage: ./producer.sh COMMAND [options]

  process RECORDING.wav...   Master one or more WAV files
  preview RECORDING.wav      Render loudness-matched profile previews
  analyze RECORDING.wav      Print measurements as JSON on stdout
  config                     Print resolved settings (TOML, or JSON with --json)
  doctor                     Check FFmpeg, data directory and CUDA driver
  setup --device cpu|cuda    Install and self-test the AI 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

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 VOICEFORGE_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" VOICEFORGE_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" ]]
}

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"
    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.
    if ! err="$("$UV" pip install --python "$APP/bin/python" --index-url https://pypi.org/simple \
            "$PROJECT[bundled-ffmpeg]" 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 "$PROJECT" \
            || fail "Installing the application dependencies failed; check network access and free space."
    fi
    "$APP/bin/python" -I -c "import voiceforge" >/dev/null 2>&1 \
        || fail "The application environment failed verification."

    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 voiceforge ${ARGS[@]+"${ARGS[@]}"}