Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium

- name: Run AI bootstrap tests
run: bash tests/test-setup-ai.sh

- name: Run server tests
run: |
# Start signaling server in background
Expand Down
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`setup-ai.sh`** one-shot bootstrap script for the AI pipeline. It clones and builds `whisper.cpp`, downloads the default Whisper model into `models/`, and prompts for LLM provider settings in `.env`.
- **`setup-ai.sh`** one-shot bootstrap script for the AI pipeline. It clones and builds `whisper.cpp`, downloads the default Whisper model into `models/`, and prompts for LLM provider settings in `.env`. Thanks to @avezou (#12).
- **Regression tests for the AI bootstrap** in `tests/test-setup-ai.sh`, covering interrupted model downloads and `.env` files holding shell-hostile secrets, alongside the original happy path.

### Fixed

- **Interrupted model downloads are no longer cached as success.** `setup-ai.sh` wrote the 1.5 GB model straight to its final path, so a dropped transfer left a truncated file that every later run accepted as valid — and because `capabilities.js` detects the model with a bare `existsSync`, the UI would un-gate Transcribe over a corrupt model. Downloads now land in `models/ggml-medium.bin.part`, are size-checked, and only then renamed into place.
- **`setup-ai.sh` no longer sources `.env` as shell.** Sourcing executed the file, so a `JWT_SECRET` containing `$` aborted the script under `set -u` and a value with spaces ran as a command. Only the three `LLM_*` keys are read now, and unrelated secrets are left untouched.
- **`setup-ai.sh` no longer aborts when ffmpeg is missing.** Neither ffmpeg nor ffprobe is needed to clone whisper.cpp, build it, or fetch the model, so their absence now warns instead of blocking the entire bootstrap.
- **The 1.5 GB model download shows progress** instead of running silent, which read as a hung script.
- **`whisper.cpp` is no longer a tracked gitlink.** It was pinned as a `160000` entry with no `.gitmodules` backing, so `.gitignore` could never apply to it and every checkout that ran `setup-ai.sh` showed a permanently dirty `modified: whisper.cpp (new commits)`. It is now untracked and ignored, which is what the setup script already assumed.

## [0.3.2] - 2026-05-25

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ OpenStudio includes a complete post-production pipeline — transcription, show

The script clones and builds `whisper.cpp`, downloads `models/ggml-medium.bin`, and prompts for your LLM provider so `.env` is ready for show notes.

It's safe to re-run. Each step is skipped if it's already done, and a download interrupted partway through is discarded rather than left half-written, so a second run picks up cleanly. The `whisper.cpp/` checkout and the model file are local build artifacts — both are gitignored and neither is tracked by this repo.

You can swap the model manually if you want — `ggml-tiny.bin` (~75 MB) is faster but less accurate; `ggml-large.bin` is the other direction. The server looks for `models/ggml-medium.bin` by default.

### LLM Provider Examples
Expand Down
79 changes: 58 additions & 21 deletions setup-ai.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ MODEL_DIR="models"
MODEL_FILE="$MODEL_DIR/ggml-medium.bin"
WHISPER_REPO="https://github.com/ggerganov/whisper.cpp.git"
MODEL_URL="https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin"
# ggml-medium.bin is ~1.5 GB; anything under 1 GB is a truncated transfer.
# Overridable so tests can exercise the truncation path without moving a real GB.
MODEL_MIN_BYTES="${MODEL_MIN_BYTES:-1000000000}"

info() { printf '%s\n' "$*"; }
ok() { printf 'OK: %s\n' "$*"; }
Expand All @@ -36,9 +39,9 @@ download_file() {
local output="$2"

if command -v curl >/dev/null 2>&1; then
curl -L --fail --silent --show-error "$url" -o "$output"
curl -L --fail --progress-bar --show-error "$url" -o "$output"
elif command -v wget >/dev/null 2>&1; then
wget -q "$url" -O "$output"
wget --show-progress -q "$url" -O "$output"
else
fail "Need curl or wget to download the Whisper model"
fi
Expand All @@ -52,11 +55,19 @@ ensure_env_file() {
fi
}

# Read a single key out of .env without sourcing it. Sourcing runs the file as
# shell, so a secret containing $, spaces, or ; either mangles the value, aborts
# under `set -u`, or executes. We only need the three LLM_* keys.
read_env_value() {
local key="$1"
[ -f "$ENV_FILE" ] || return 0
sed -n "s/^[[:space:]]*${key}=//p" "$ENV_FILE" | tail -n 1
}

load_env() {
set -a
# shellcheck disable=SC1091
. "./$ENV_FILE"
set +a
LLM_BASE_URL="$(read_env_value LLM_BASE_URL)"
LLM_MODEL="$(read_env_value LLM_MODEL)"
LLM_API_KEY="$(read_env_value LLM_API_KEY)"
}

set_env_value() {
Expand Down Expand Up @@ -107,11 +118,6 @@ ensure_whisper_cpp() {
elif [ "$has_build_files" -eq 1 ]; then
ok "Found whisper.cpp"
fi

if [ -x "$WHISPER_BIN" ]; then
ok "whisper.cpp already built"
return
fi
}

build_whisper_cpp() {
Expand Down Expand Up @@ -145,17 +151,44 @@ build_whisper_cpp() {
ok "Built whisper.cpp"
}

file_size() {
wc -c < "$1" | tr -d '[:space:]'
}

ensure_model() {
mkdir -p "$MODEL_DIR"

local partial="$MODEL_FILE.part"

if [ -f "$MODEL_FILE" ]; then
ok "Whisper model already present"
return
if [ "$(file_size "$MODEL_FILE")" -lt "$MODEL_MIN_BYTES" ]; then
warn "Existing model is only $(file_size "$MODEL_FILE") bytes — truncated, re-downloading"
rm -f "$MODEL_FILE"
else
ok "Whisper model already present"
return
fi
fi

info "Downloading Whisper model..."
download_file "$MODEL_URL" "$MODEL_FILE"
[ -f "$MODEL_FILE" ] || fail "Model download failed"
# Download to .part and rename only on success. Writing straight to the final
# path means an interrupted transfer leaves a truncated file that every later
# run accepts as valid -- and capabilities.js detects the model with a bare
# existsSync, so the UI would un-gate Transcribe over a corrupt model.
rm -f "$partial"
info "Downloading Whisper model (~1.5 GB, this takes a while)..."
if ! download_file "$MODEL_URL" "$partial"; then
rm -f "$partial"
fail "Model download failed. Re-run ./setup-ai.sh to retry."
fi

if [ ! -f "$partial" ] || [ "$(file_size "$partial")" -lt "$MODEL_MIN_BYTES" ]; then
local got="0"
[ -f "$partial" ] && got="$(file_size "$partial")"
rm -f "$partial"
fail "Model download truncated (got ${got} bytes, expected at least ${MODEL_MIN_BYTES}). Re-run ./setup-ai.sh to retry."
fi

mv "$partial" "$MODEL_FILE"
ok "Downloaded Whisper model"
}

Expand Down Expand Up @@ -256,12 +289,16 @@ main() {
require_cmd git
require_cmd awk

if ! command -v ffmpeg >/dev/null 2>&1; then
fail "ffmpeg is required. Install it first (brew install ffmpeg or apt install ffmpeg)."
fi
# ffmpeg/ffprobe are needed by the cleaning and export pipeline at runtime, but
# not to clone whisper.cpp, build it, or fetch the model. Don't block the whole
# bootstrap on them -- warn and let the user finish the parts that do work.
local missing_ffmpeg=0
command -v ffmpeg >/dev/null 2>&1 || missing_ffmpeg=1
command -v ffprobe >/dev/null 2>&1 || missing_ffmpeg=1

if ! command -v ffprobe >/dev/null 2>&1; then
fail "ffprobe is required. Install it with ffmpeg before continuing."
if [ "$missing_ffmpeg" -eq 1 ]; then
warn "ffmpeg/ffprobe not found. Transcribe and export stay gated until you install them:"
warn " macOS: brew install ffmpeg Debian/Ubuntu: sudo apt install ffmpeg"
fi

ensure_env_file
Expand Down
166 changes: 106 additions & 60 deletions tests/test-setup-ai.sh
Original file line number Diff line number Diff line change
@@ -1,35 +1,40 @@
#!/bin/bash
# Smoke tests for setup-ai.sh, run against a fake toolchain (no network, no
# real build). Scenario 1 is the original happy path; scenarios 2 and 3 are
# regressions for bugs found reviewing PR #12.
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT

cp "$ROOT_DIR/setup-ai.sh" "$TMPDIR/setup-ai.sh"
cp "$ROOT_DIR/.env.example" "$TMPDIR/.env.example"
mkdir -p "$TMPDIR/fakebin" "$TMPDIR/fake-whisper-src"
: > "$TMPDIR/fake-whisper-src/CMakeLists.txt"

cat > "$TMPDIR/fakebin/git" <<'EOF'
PASSED=0

# Real ggml-medium.bin is ~1.5 GB. Shrink the floor so the fake toolchain can
# exercise both sides of the truncation check without moving a gigabyte.
export MODEL_MIN_BYTES=16

# Builds an isolated sandbox with a fake git/cmake/ffmpeg/ffprobe on PATH.
# $1 = sandbox dir. The caller supplies its own fakebin/curl.
make_sandbox() {
local dir="$1"
mkdir -p "$dir/fakebin" "$dir/fake-whisper-src"
cp "$ROOT_DIR/setup-ai.sh" "$dir/setup-ai.sh"
cp "$ROOT_DIR/.env.example" "$dir/.env.example"
: > "$dir/fake-whisper-src/CMakeLists.txt"

cat > "$dir/fakebin/git" <<'EOF'
#!/bin/bash
set -euo pipefail

if [ "${1:-}" = "clone" ]; then
target="${@: -1}"
src="${FAKE_WHISPER_SRC:?}"
rm -rf "$target"
mkdir -p "$target"
cp -R "$src"/. "$target"/
rm -rf "$target"; mkdir -p "$target"; cp -R "$src"/. "$target"/
exit 0
fi

command git "$@"
EOF

cat > "$TMPDIR/fakebin/cmake" <<'EOF'
cat > "$dir/fakebin/cmake" <<'EOF'
#!/bin/bash
set -euo pipefail

if [ "${1:-}" = "-S" ]; then
builddir=""
while [ "$#" -gt 0 ]; do
Expand All @@ -38,60 +43,101 @@ if [ "${1:-}" = "-S" ]; then
*) shift ;;
esac
done

mkdir -p "$builddir/bin"
cat > "$builddir/bin/whisper-cli" <<'EOB'
#!/bin/sh
exit 0
EOB
printf '#!/bin/sh\nexit 0\n' > "$builddir/bin/whisper-cli"
chmod +x "$builddir/bin/whisper-cli"
exit 0
fi

exit 0
EOF

cat > "$TMPDIR/fakebin/curl" <<'EOF'
#!/bin/bash
set -euo pipefail
printf '#!/bin/sh\nexit 0\n' > "$dir/fakebin/ffmpeg"
printf '#!/bin/sh\nexit 0\n' > "$dir/fakebin/ffprobe"
chmod +x "$dir/fakebin/"*
}

# A curl stub. $1 = sandbox, $2 = payload to write, $3 = exit code.
fake_curl() {
local dir="$1" payload="$2" code="$3"
cat > "$dir/fakebin/curl" <<EOF
#!/bin/bash
out=""
while [ "$#" -gt 0 ]; do
case "$1" in
-o) out="$2"; shift 2 ;;
while [ "\$#" -gt 0 ]; do
case "\$1" in
-o) out="\$2"; shift 2 ;;
*) shift ;;
esac
done

[ -n "$out" ] || exit 1
mkdir -p "$(dirname "$out")"
printf 'model' > "$out"
EOF

cat > "$TMPDIR/fakebin/ffmpeg" <<'EOF'
#!/bin/sh
exit 0
EOF

cat > "$TMPDIR/fakebin/ffprobe" <<'EOF'
#!/bin/sh
exit 0
[ -n "\$out" ] || exit 1
mkdir -p "\$(dirname "\$out")"
printf '%s' '$payload' > "\$out"
exit $code
EOF

chmod +x "$TMPDIR/fakebin/"*

(
cd "$TMPDIR"
PATH="$TMPDIR/fakebin:$PATH" FAKE_WHISPER_SRC="$TMPDIR/fake-whisper-src" \
bash ./setup-ai.sh <<'EOF'
2
chmod +x "$dir/fakebin/curl"
}

run_setup() {
local dir="$1" choice="$2"
( cd "$dir" && PATH="$dir/fakebin:$PATH" FAKE_WHISPER_SRC="$dir/fake-whisper-src" \
bash ./setup-ai.sh <<< "$choice" )
}

pass() { PASSED=$((PASSED + 1)); echo " PASS: $1"; }
die() { echo " FAIL: $1" >&2; exit 1; }

# --- Scenario 1: happy path, Ollama selected -------------------------------
echo "[1] happy path writes provider config and lands both artifacts"
S1="$(mktemp -d)"; trap 'rm -rf "$S1"' EXIT
make_sandbox "$S1"
fake_curl "$S1" "MODEL-PAYLOAD-LONG-ENOUGH" 0
run_setup "$S1" "2" > "$S1/out.log" 2>&1 || die "setup-ai.sh exited nonzero"

grep -q '^LLM_BASE_URL=http://localhost:11434/v1$' "$S1/.env" || die "LLM_BASE_URL not written"
grep -q '^LLM_MODEL=llama3.3$' "$S1/.env" || die "LLM_MODEL not written"
grep -q '^LLM_API_KEY=$' "$S1/.env" || die "LLM_API_KEY not blanked"
[ -x "$S1/whisper.cpp/build/bin/whisper-cli" ] || die "whisper-cli missing"
[ -f "$S1/models/ggml-medium.bin" ] || die "model missing"
pass "provider config, whisper-cli, and model all present"

# --- Scenario 2: interrupted download must not be cached as success --------
# Regression: writing straight to the final path left a truncated file that
# later runs accepted, and capabilities.js only checks existence -- so the UI
# would un-gate Transcribe over a corrupt model.
echo "[2] interrupted download is discarded, then recovers on retry"
S2="$(mktemp -d)"; trap 'rm -rf "$S1" "$S2"' EXIT
make_sandbox "$S2"
fake_curl "$S2" "TRUNC" 18 # curl(18): transfer closed early
if run_setup "$S2" "1" > "$S2/fail.log" 2>&1; then
die "setup-ai.sh reported success despite a failed download"
fi
[ ! -f "$S2/models/ggml-medium.bin" ] || die "truncated model left on disk"
[ ! -f "$S2/models/ggml-medium.bin.part" ] || die ".part file left on disk"
pass "truncated transfer left no model behind"

fake_curl "$S2" "MODEL-PAYLOAD-LONG-ENOUGH" 0
run_setup "$S2" "1" > "$S2/retry.log" 2>&1 || die "retry after failed download did not succeed"
[ -f "$S2/models/ggml-medium.bin" ] || die "retry did not produce a model"
pass "retry downloaded the model cleanly"

# --- Scenario 3: .env holding real secrets must not break the script -------
# Regression: load_env sourced .env as shell, so a secret containing $ aborted
# under `set -u` and a value with spaces ran as a command.
echo "[3] .env with shell-hostile secrets is read, not executed"
S3="$(mktemp -d)"; trap 'rm -rf "$S1" "$S2" "$S3"' EXIT
make_sandbox "$S3"
fake_curl "$S3" "MODEL-PAYLOAD-LONG-ENOUGH" 0
cat > "$S3/.env" <<'EOF'
JWT_SECRET=k7$Rm2pQ9wZx
STATION_NAME=Pirate Radio One
ICECAST_SOURCE_PASSWORD=a;b`c`
LLM_BASE_URL=http://localhost:1234/v1
LLM_MODEL=qwen3.5-35b
LLM_API_KEY=
EOF
)

grep -q '^LLM_BASE_URL=http://localhost:11434/v1$' "$TMPDIR/.env"
grep -q '^LLM_MODEL=llama3.3$' "$TMPDIR/.env"
grep -q '^LLM_API_KEY=$' "$TMPDIR/.env"
[ -x "$TMPDIR/whisper.cpp/build/bin/whisper-cli" ]
[ -f "$TMPDIR/models/ggml-medium.bin" ]

echo "setup-ai.sh test passed"
run_setup "$S3" "2" > "$S3/out.log" 2>&1 || die "setup-ai.sh choked on a realistic .env"
grep -q '^JWT_SECRET=k7\$Rm2pQ9wZx$' "$S3/.env" || die "JWT_SECRET was mangled"
grep -q '^STATION_NAME=Pirate Radio One$' "$S3/.env" || die "STATION_NAME was mangled"
grep -q '^LLM_BASE_URL=http://localhost:11434/v1$' "$S3/.env" || die "LLM_BASE_URL not updated"
pass "unrelated secrets preserved verbatim, LLM keys updated"

echo ""
echo "setup-ai.sh test passed ($PASSED checks)"
1 change: 0 additions & 1 deletion whisper.cpp
Submodule whisper.cpp deleted from 1fe009
Loading