diff --git a/.gitignore b/.gitignore index 7d20525..29047dd 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ certs/ memory-bank/epoch/* !memory-bank/epoch/.gitkeep models/*.bin +whisper.cpp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a45f06..4429534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to OpenStudio will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### 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`. + ## [0.3.2] - 2026-05-25 ### Added diff --git a/QUICKSTART.md b/QUICKSTART.md index 6efa626..d4cecf4 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -9,6 +9,8 @@ cp .env.example .env cd server && npm install && cd .. cd web && npm install && cd .. cp station-manifest.sample.json station-manifest.json +# Optional: bootstrap the AI pipeline (whisper.cpp, model, LLM config) +./setup-ai.sh ``` ## Daily Development Workflow diff --git a/README.md b/README.md index 001330c..048f23c 100644 --- a/README.md +++ b/README.md @@ -132,18 +132,18 @@ OpenStudio includes a complete post-production pipeline — transcription, show ### Prerequisites - `ffmpeg` and `ffprobe` on your `PATH` (most package managers ship both: `brew install ffmpeg`, `apt install ffmpeg`) +- `cmake` on your `PATH` for the `whisper.cpp` build - ~1.5 GB free disk space for the default Whisper model -### whisper.cpp setup (one-time) +### One-shot AI setup ```bash -git clone https://github.com/ggerganov/whisper.cpp -cd whisper.cpp && make -j$(nproc) -cd .. && mkdir -p models -wget -O models/ggml-medium.bin https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin +./setup-ai.sh ``` -You can swap the model — `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. +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. + +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 @@ -240,8 +240,6 @@ See [docs/vision.md](docs/vision.md) for the full project vision and philosophy. Honest about what's there and what isn't: - **Invite-link UI** — the server can mint scoped invite tokens (host / ops / guest, 4 h TTL), but the host UI doesn't expose a button yet. For now, hosts share the room URL manually. Coming in 0.4. -- **AI pipeline setup is manual** — the whisper.cpp build, model download, and LLM configuration aren't scripted. A `setup-ai.sh` would be a great PR. -- **whisper.cpp gitlink** — the repo references whisper.cpp as a gitlink without a `.gitmodules` entry. Use the manual `git clone` in the AI setup section above; `git submodule update` will fail. - **Mesh scale ceiling** — WebRTC mesh tops out around 15 participants. Larger rooms need an SFU (planned for 0.5). ## Contributing diff --git a/package.json b/package.json index 5e7535e..13db3a3 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "start": "node server/server.js", "dev": "node --watch server/server.js", "test": "node server/test-signaling.js && node server/test-rooms.js", + "test:setup-ai": "bash tests/test-setup-ai.sh", "install": "cd server && npm install" }, "engines": { diff --git a/run-pre-validation.sh b/run-pre-validation.sh index 979a204..78d5d3c 100755 --- a/run-pre-validation.sh +++ b/run-pre-validation.sh @@ -18,6 +18,7 @@ echo " 3. test-gain-controls.mjs - Per-participant gain controls" echo " 4. test-program-bus.mjs - Program bus mixing" echo " 5. test-mix-minus.mjs - Mix-minus calculation (3 peers)" echo " 6. test-return-feed.mjs - Return feed routing (2 peers)" +echo " 7. test-setup-ai.sh - AI bootstrap script smoke test" echo "" echo "================================================================================" echo "" @@ -32,13 +33,18 @@ run_test() { local test_name=$1 local test_file=$2 local timeout_seconds=${3:-60} + local runner="node" + + case "$test_file" in + *.sh) runner="bash" ;; + esac echo "--- Running: $test_name ---" echo "File: $test_file" echo "Timeout: ${timeout_seconds}s" echo "" - if timeout ${timeout_seconds} node "$test_file"; then + if timeout ${timeout_seconds} "$runner" "$test_file"; then echo "" echo "✅ PASSED: $test_name" echo "" @@ -64,6 +70,7 @@ run_test "Gain Controls" "tests/test-gain-controls.mjs" 60 run_test "Program Bus Mixing" "tests/test-program-bus.mjs" 60 run_test "Mix-Minus Calculation" "tests/test-mix-minus.mjs" 60 run_test "Return Feed Routing" "tests/test-return-feed.mjs" 60 +run_test "AI Bootstrap Script" "tests/test-setup-ai.sh" 120 # Summary echo "================================================================================" @@ -76,7 +83,7 @@ for test in "${TESTS[@]}"; do done echo "" -echo "Results: $PASSED passed, $FAILED failed (out of 6 total)" +echo "Results: $PASSED passed, $FAILED failed (out of 7 total)" echo "" # Decision diff --git a/server/lib/whisper-transcriber.js b/server/lib/whisper-transcriber.js index 546e28e..f94cff4 100644 --- a/server/lib/whisper-transcriber.js +++ b/server/lib/whisper-transcriber.js @@ -175,12 +175,41 @@ async function downloadModel(model = 'medium') { */ async function buildWhisperCpp() { const whisperDir = path.join(process.cwd(), 'whisper.cpp'); + const whisperBin = WHISPER_BIN; + const cmakeLists = path.join(whisperDir, 'CMakeLists.txt'); + const makefile = path.join(whisperDir, 'Makefile'); + + if (!fs.existsSync(whisperDir)) { + logger.info('Cloning whisper.cpp...'); + await execAsync(`git clone --depth 1 --recursive https://github.com/ggerganov/whisper.cpp.git ${whisperDir}`); + } else if (!fs.existsSync(cmakeLists) && !fs.existsSync(makefile)) { + const entries = fs.readdirSync(whisperDir); + if (entries.length === 0) { + logger.warn('Found empty whisper.cpp directory, re-cloning...'); + fs.rmSync(whisperDir, { recursive: true, force: true }); + await execAsync(`git clone --depth 1 --recursive https://github.com/ggerganov/whisper.cpp.git ${whisperDir}`); + } else { + throw new Error('whisper.cpp checkout exists but has no supported build files'); + } + } - logger.info('Cloning whisper.cpp...'); - await execAsync(`git clone --depth 1 https://github.com/ggerganov/whisper.cpp.git ${whisperDir}`); + if (fs.existsSync(whisperBin)) { + logger.info('whisper.cpp already built'); + return; + } - logger.info('Building whisper.cpp...'); - await execAsync(`cd ${whisperDir} && make -j$(nproc) 2>&1 | tail -5`); + if (fs.existsSync(cmakeLists)) { + logger.info('Configuring whisper.cpp with CMake...'); + await execAsync(`cmake -S ${whisperDir} -B ${whisperDir}/build -DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON -DWHISPER_BUILD_SERVER=OFF`); + + logger.info('Building whisper.cpp...'); + await execAsync(`cmake --build ${whisperDir}/build --config Release --target whisper-cli -j$(nproc)`); + } else if (fs.existsSync(makefile)) { + logger.info('Building whisper.cpp...'); + await execAsync(`cd ${whisperDir} && make -j$(nproc) 2>&1 | tail -5`); + } else { + throw new Error('whisper.cpp checkout does not contain a supported build system'); + } logger.info('whisper.cpp built successfully'); } diff --git a/setup-ai.sh b/setup-ai.sh new file mode 100755 index 0000000..c513751 --- /dev/null +++ b/setup-ai.sh @@ -0,0 +1,283 @@ +#!/bin/bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT_DIR" + +ENV_FILE=".env" +WHISPER_DIR="whisper.cpp" +WHISPER_BIN="$WHISPER_DIR/build/bin/whisper-cli" +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" + +info() { printf '%s\n' "$*"; } +ok() { printf 'OK: %s\n' "$*"; } +warn() { printf 'WARN: %s\n' "$*" >&2; } +fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1" +} + +cpu_jobs() { + if command -v nproc >/dev/null 2>&1; then + nproc + elif command -v sysctl >/dev/null 2>&1; then + sysctl -n hw.ncpu + else + echo 4 + fi +} + +download_file() { + local url="$1" + local output="$2" + + if command -v curl >/dev/null 2>&1; then + curl -L --fail --silent --show-error "$url" -o "$output" + elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$output" + else + fail "Need curl or wget to download the Whisper model" + fi +} + +ensure_env_file() { + if [ ! -f "$ENV_FILE" ]; then + [ -f .env.example ] || fail "Missing .env.example" + cp .env.example "$ENV_FILE" + ok "Created .env from .env.example" + fi +} + +load_env() { + set -a + # shellcheck disable=SC1091 + . "./$ENV_FILE" + set +a +} + +set_env_value() { + local key="$1" + local value="$2" + local tmp + tmp="$(mktemp)" + + awk -v key="$key" -v value="$value" ' + BEGIN { found = 0 } + $0 ~ "^" key "=" { print key "=" value; found = 1; next } + { print } + END { if (!found) print key "=" value } + ' "$ENV_FILE" > "$tmp" + + mv "$tmp" "$ENV_FILE" +} + +prompt_default() { + local prompt="$1" + local default_value="$2" + local reply + + printf '%s [%s]: ' "$prompt" "$default_value" >&2 + read -r reply + printf '%s\n' "${reply:-$default_value}" +} + +ensure_whisper_cpp() { + local has_build_files=0 + + if [ -f "$WHISPER_DIR/CMakeLists.txt" ] || [ -f "$WHISPER_DIR/Makefile" ]; then + has_build_files=1 + fi + + if [ -d "$WHISPER_DIR" ] && [ "$has_build_files" -eq 0 ]; then + if [ -z "$(find "$WHISPER_DIR" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then + warn "Found empty whisper.cpp/ directory; replacing it with a fresh clone" + else + warn "Found whisper.cpp/ without build files; replacing it with a fresh clone" + fi + rm -rf "$WHISPER_DIR" + fi + + if [ ! -d "$WHISPER_DIR" ]; then + info "Cloning whisper.cpp..." + git clone --depth 1 --recursive "$WHISPER_REPO" "$WHISPER_DIR" + 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() { + if [ -x "$WHISPER_BIN" ]; then + ok "whisper.cpp already built" + return + fi + + local jobs + jobs="$(cpu_jobs)" + + if [ -f "$WHISPER_DIR/CMakeLists.txt" ]; then + require_cmd cmake + info "Configuring whisper.cpp with CMake..." + cmake -S "$WHISPER_DIR" -B "$WHISPER_DIR/build" \ + -DWHISPER_BUILD_TESTS=OFF \ + -DWHISPER_BUILD_EXAMPLES=ON \ + -DWHISPER_BUILD_SERVER=OFF + + info "Building whisper.cpp..." + cmake --build "$WHISPER_DIR/build" --config Release --target whisper-cli -j"$jobs" + elif [ -f "$WHISPER_DIR/Makefile" ]; then + require_cmd make + info "Building whisper.cpp..." + (cd "$WHISPER_DIR" && make -j"$jobs") + else + fail "whisper.cpp is missing supported build files" + fi + + [ -x "$WHISPER_BIN" ] || fail "whisper.cpp build finished but $WHISPER_BIN is missing" + ok "Built whisper.cpp" +} + +ensure_model() { + mkdir -p "$MODEL_DIR" + + if [ -f "$MODEL_FILE" ]; then + ok "Whisper model already present" + return + fi + + info "Downloading Whisper model..." + download_file "$MODEL_URL" "$MODEL_FILE" + [ -f "$MODEL_FILE" ] || fail "Model download failed" + ok "Downloaded Whisper model" +} + +configure_llm() { + local current_base_url="${LLM_BASE_URL:-http://localhost:1234/v1}" + local current_model="${LLM_MODEL:-qwen3.5-35b}" + local current_api_key="${LLM_API_KEY:-}" + local choice + local provider_label + local base_url + local model + local api_key + + info "" + info "Choose an LLM provider for show notes:" + info " 1) LM Studio (default)" + info " 2) Ollama" + info " 3) OpenAI" + info " 4) Together AI" + info " 5) Groq" + info " 6) Custom OpenAI-compatible / shim" + printf 'Provider [1]: ' + read -r choice + choice="${choice:-1}" + + case "$choice" in + 1) + provider_label="LM Studio" + base_url="http://localhost:1234/v1" + model="qwen3.5-35b" + api_key="" + ;; + 2) + provider_label="Ollama" + base_url="http://localhost:11434/v1" + model="llama3.3" + api_key="" + ;; + 3) + provider_label="OpenAI" + base_url="https://api.openai.com/v1" + model="gpt-4o-mini" + printf 'Model [gpt-4o-mini]: ' + read -r model + model="${model:-gpt-4o-mini}" + printf 'API key: ' + read -r -s api_key + printf '\n' + ;; + 4) + provider_label="Together AI" + base_url="https://api.together.xyz/v1" + model="meta-llama/Llama-3.3-70B-Instruct-Turbo" + printf 'Model [meta-llama/Llama-3.3-70B-Instruct-Turbo]: ' + read -r model + model="${model:-meta-llama/Llama-3.3-70B-Instruct-Turbo}" + printf 'API key: ' + read -r -s api_key + printf '\n' + ;; + 5) + provider_label="Groq" + base_url="https://api.groq.com/openai/v1" + model="llama-3.3-70b-versatile" + printf 'Model [llama-3.3-70b-versatile]: ' + read -r model + model="${model:-llama-3.3-70b-versatile}" + printf 'API key: ' + read -r -s api_key + printf '\n' + ;; + 6) + provider_label="Custom" + base_url="$(prompt_default 'Base URL' "$current_base_url")" + model="$(prompt_default 'Model' "$current_model")" + printf 'API key [leave blank if none]: ' + read -r -s api_key + printf '\n' + api_key="${api_key:-$current_api_key}" + ;; + *) + warn "Unknown choice, using LM Studio defaults" + provider_label="LM Studio" + base_url="http://localhost:1234/v1" + model="qwen3.5-35b" + api_key="" + ;; + esac + + set_env_value "LLM_BASE_URL" "$base_url" + set_env_value "LLM_MODEL" "$model" + set_env_value "LLM_API_KEY" "$api_key" + + ok "Configured LLM provider: $provider_label" +} + +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 + + if ! command -v ffprobe >/dev/null 2>&1; then + fail "ffprobe is required. Install it with ffmpeg before continuing." + fi + + ensure_env_file + load_env + + ensure_whisper_cpp + build_whisper_cpp + ensure_model + configure_llm + + info "" + ok "AI pipeline setup complete" + info "Next steps:" + info " 1) Start OpenStudio: npm start" + info " 2) Or run the dev workflow: ./dev.sh start" + info " 3) Reload the studio UI so /api/capabilities picks up the new tools" +} + +main "$@" diff --git a/tests/test-setup-ai.sh b/tests/test-setup-ai.sh new file mode 100755 index 0000000..85d2b59 --- /dev/null +++ b/tests/test-setup-ai.sh @@ -0,0 +1,97 @@ +#!/bin/bash +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' +#!/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"/ + exit 0 +fi + +command git "$@" +EOF + +cat > "$TMPDIR/fakebin/cmake" <<'EOF' +#!/bin/bash +set -euo pipefail + +if [ "${1:-}" = "-S" ]; then + builddir="" + while [ "$#" -gt 0 ]; do + case "$1" in + -B) builddir="$2"; shift 2 ;; + *) shift ;; + esac + done + + mkdir -p "$builddir/bin" + cat > "$builddir/bin/whisper-cli" <<'EOB' +#!/bin/sh +exit 0 +EOB + chmod +x "$builddir/bin/whisper-cli" + exit 0 +fi + +exit 0 +EOF + +cat > "$TMPDIR/fakebin/curl" <<'EOF' +#!/bin/bash +set -euo pipefail + +out="" +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 +EOF + +chmod +x "$TMPDIR/fakebin/"* + +( + cd "$TMPDIR" + PATH="$TMPDIR/fakebin:$PATH" FAKE_WHISPER_SRC="$TMPDIR/fake-whisper-src" \ + bash ./setup-ai.sh <<'EOF' +2 +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" diff --git a/web/js/main.js b/web/js/main.js index 42aa811..a50de02 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -283,8 +283,7 @@ class OpenStudioApp { out.push({ name: 'whisper.cpp', present: whisperPresent, - install: - 'git clone https://github.com/ggerganov/whisper.cpp\ncd whisper.cpp && make -j$(nproc)', + install: './setup-ai.sh # clones whisper.cpp and builds whisper-cli', }); const modelPresent = !!(caps.model && caps.model.available); @@ -292,8 +291,7 @@ class OpenStudioApp { out.push({ name: `Whisper model${sizeHint}`, present: modelPresent, - install: - 'mkdir -p models\nwget -O models/ggml-medium.bin \\\n https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin', + install: './setup-ai.sh # downloads the medium Whisper model into models/', }); } @@ -304,8 +302,7 @@ class OpenStudioApp { out.push({ name: `LLM polish (optional) — ${caps.llm.provider || 'OpenAI-compatible'}`, present: llmReady, - install: - '# In .env:\nLLM_BASE_URL=http://localhost:1234/v1 # LM Studio default\nLLM_MODEL=qwen3.5-35b\n# Show notes still generate without an LLM — they just fall back\n# to a transcript-derived title and summary.', + install: './setup-ai.sh # choose an LLM provider and write it into .env', }); } diff --git a/whisper.cpp b/whisper.cpp index 46ca43d..1fe009c 160000 --- a/whisper.cpp +++ b/whisper.cpp @@ -1 +1 @@ -Subproject commit 46ca43d6399fdeada1b49fb2126ba373bd9ebc38 +Subproject commit 1fe009caeda75f69bc864d6370b10674e45a92bd