#!/usr/bin/env bash # # meetings — audio → transcript → summary + action items, all local # # Transcribes audio using whisper.cpp or parakeet.cpp (GGUF/GGML models), # then uses Ollama to summarize and extract action items. # # Usage: # ./meetings # full pipeline # ./meetings setup # install deps + download model # ./meetings doctor # check dependencies # ./meetings config # show current config # set -euo pipefail # ─── colours ──────────────────────────────────────────────────────── RED='\033[0;31m'; GRN='\033[0;32m'; YEL='\033[1;33m' BLU='\033[0;34m'; CYN='\033[0;36m'; RST='\033[0m' BOLD='\033[1m' # ─── paths ────────────────────────────────────────────────────────── MEETINGS_DIR="${MEETINGS_DIR:-$HOME/.meetings}" CONFIG_FILE="$MEETINGS_DIR/config" MODELS_DIR="$MEETINGS_DIR/models" # ─── helpers ─────────────────────────────────────────────────────── die() { printf "${RED}error: %s${RST}\n" "$*" >&2; exit 1; } info() { printf "${BLU}▸ %s${RST}\n" "$*" >&2; } ok() { printf "${GRN}✓ %s${RST}\n" "$*" >&2; } warn() { printf "${YEL}⚠ %s${RST}\n" "$*" >&2; } step() { printf "\n${BOLD}${CYN}── %s ──${RST}\n" "$*" >&2; } banner() { cat <<'BAN' ┌─────────────────────────────────────────────┐ │ 🎤 M E E T I N G S │ │ audio → transcript → summary + actions │ │ whisper.cpp · parakeet.cpp · ollama │ └─────────────────────────────────────────────┘ BAN } # ─── load config: env vars > config file > defaults ──────────────── load_config() { # defaults STT_ENGINE="" STT_MODEL="" OLLAMA_MODEL="llama3.1:8b" OLLAMA_HOST="http://localhost:11434" THREADS="4" LANGUAGE="en" OUTPUT_DIR="." # config file overrides defaults [[ -f "$CONFIG_FILE" ]] && source "$CONFIG_FILE" # env vars override everything (|| true prevents set -e exit on empty vars) [[ -n "${MEETINGS_STT:-}" ]] && STT_ENGINE="$MEETINGS_STT" || true [[ -n "${MEETINGS_STT_MODEL:-}" ]] && STT_MODEL="$MEETINGS_STT_MODEL" || true [[ -n "${MEETINGS_LLM:-}" ]] && OLLAMA_MODEL="$MEETINGS_LLM" || true [[ -n "${MEETINGS_THREADS:-}" ]] && THREADS="$MEETINGS_THREADS" || true [[ -n "${MEETINGS_LANG:-}" ]] && LANGUAGE="$MEETINGS_LANG" || true [[ -n "${MEETINGS_OUTPUT:-}" ]] && OUTPUT_DIR="$MEETINGS_OUTPUT" || true } # ─── detect STT engine ────────────────────────────────────────────── detect_stt() { if command -v parakeet-cli &>/dev/null; then echo "parakeet" elif command -v whisper-cli &>/dev/null; then echo "whisper" else echo "" fi } get_stt_binary() { local engine="${1:-$STT_ENGINE}" case "$engine" in whisper) command -v whisper-cli 2>/dev/null || echo "" ;; parakeet) command -v parakeet-cli 2>/dev/null || echo "" ;; *) echo "" ;; esac } find_stt_model() { local engine="${1:-whisper}" if [[ -n "${STT_MODEL:-}" && -f "${STT_MODEL}" ]]; then echo "$STT_MODEL"; return 0 fi case "$engine" in whisper) for f in "$MODELS_DIR"/ggml-*.bin "$MODELS_DIR"/ggml-*.gguf; do [[ -f "$f" ]] && echo "$f" && return 0 done ;; parakeet) for f in "$MODELS_DIR"/*parakeet*.gguf "$MODELS_DIR"/*tdt*.gguf; do [[ -f "$f" ]] && echo "$f" && return 0 done ;; esac return 1 } # ─── doctor ───────────────────────────────────────────────────────── cmd_doctor() { banner >&2 local ok_count=0 total=0 total=$((total+1)) if command -v ffmpeg &>/dev/null; then ok "ffmpeg: $(command -v ffmpeg)"; ok_count=$((ok_count+1)) else warn "ffmpeg: not found" fi total=$((total+1)) if command -v ollama &>/dev/null; then ok "ollama: $(command -v ollama)"; ok_count=$((ok_count+1)) if curl -sf "${OLLAMA_HOST:-http://localhost:11434}/api/tags" &>/dev/null; then ok " server: running at ${OLLAMA_HOST:-http://localhost:11434}" else warn " server: not responding (run: ollama serve)" fi else warn "ollama: not found" fi total=$((total+1)) local engine="${STT_ENGINE:-$(detect_stt)}" local bin bin="$(get_stt_binary "$engine")" if [[ -n "$bin" ]]; then ok "STT engine: $engine ($bin)"; ok_count=$((ok_count+1)) else warn "STT engine: not found (run: ./meetings setup)" fi total=$((total+1)) local model model="$(find_stt_model "$engine")" || true if [[ -n "$model" ]]; then ok "STT model: $model"; ok_count=$((ok_count+1)) else warn "STT model: not found (run: ./meetings setup)" fi total=$((total+1)) local ollama_model="${OLLAMA_MODEL:-llama3.1:8b}" local model_name model_name="$(echo "$ollama_model" | cut -d: -f1)" if ollama list 2>/dev/null | awk '{print $1}' | grep -qF "$model_name"; then ok "LLM model: $ollama_model (pulled)"; ok_count=$((ok_count+1)) else warn "LLM model: $ollama_model (not pulled — run: ollama pull $ollama_model)" fi echo "" >&2 if [[ $ok_count -eq $total ]]; then ok "All good ($ok_count/$total)" else warn "Ready ($ok_count/$total). Run './meetings setup' to install missing pieces." fi } # ─── setup ────────────────────────────────────────────────────────── cmd_setup() { load_config banner >&2 mkdir -p "$MEETINGS_DIR" "$MODELS_DIR" # ── ffmpeg ── step "Checking ffmpeg" if command -v ffmpeg &>/dev/null; then ok "ffmpeg already installed" else info "Installing ffmpeg..." if [[ "$(uname)" == "Darwin" ]]; then brew install ffmpeg || die "Could not install ffmpeg" elif command -v apt-get &>/dev/null; then sudo apt-get update && sudo apt-get install -y ffmpeg elif command -v dnf &>/dev/null; then sudo dnf install -y ffmpeg else die "Please install ffmpeg manually: https://ffmpeg.org/download.html" fi fi # ── ollama ── step "Checking Ollama" if command -v ollama &>/dev/null; then ok "ollama already installed" else info "Installing Ollama..." curl -fsSL https://ollama.com/install.sh | sh || die "Could not install Ollama" fi if ! curl -sf "${OLLAMA_HOST:-http://localhost:11434}/api/tags" &>/dev/null; then info "Starting Ollama server..." ollama serve &>/dev/null & sleep 3 fi # ── choose STT engine ── step "Choosing STT engine" echo "" >&2 echo " 1) whisper.cpp — battle-tested, many languages, brew install" >&2 echo " 2) parakeet.cpp — faster, great English, smaller footprint" >&2 echo "" >&2 local choice if [[ -n "${STT_ENGINE:-}" ]]; then choice="$STT_ENGINE" info "Using pre-configured engine: $choice" else read -rp " Choose [1/2, default=1]: " choice case "${choice:-1}" in 2|parakeet) choice="parakeet" ;; *) choice="whisper" ;; esac fi case "$choice" in whisper) setup_whisper ;; parakeet) setup_parakeet ;; esac STT_ENGINE="$choice" grep -q "^STT_ENGINE=" "$CONFIG_FILE" 2>/dev/null \ && sed -i '' "s/^STT_ENGINE=.*/STT_ENGINE=$choice/" "$CONFIG_FILE" 2>/dev/null \ || echo "STT_ENGINE=$choice" >> "$CONFIG_FILE" # ── pull LLM ── step "Pulling LLM: ${OLLAMA_MODEL:-llama3.1:8b}" local ollama_model="${OLLAMA_MODEL:-llama3.1:8b}" if ollama list 2>/dev/null | awk '{print $1}' | grep -qF "$(echo "$ollama_model" | cut -d: -f1)"; then ok "$ollama_model already pulled" else ollama pull "$ollama_model" || warn "Could not pull $ollama_model. Run: ollama pull $ollama_model" fi echo "" >&2 ok "Setup complete! Run './meetings doctor' to verify, then './meetings ' to process." } setup_whisper() { step "Installing whisper.cpp" if command -v whisper-cli &>/dev/null; then ok "whisper-cli already available at $(command -v whisper-cli)" else info "Installing via package manager..." if [[ "$(uname)" == "Darwin" ]]; then brew install whisper-cpp || die "Could not install whisper-cpp via brew" else die "Please install whisper-cli manually: https://github.com/ggml-org/whisper.cpp On macOS: brew install whisper-cpp Or build from source: git clone https://github.com/ggml-org/whisper.cpp && cd whisper.cpp && cmake -B build && cmake --build build -j" fi fi step "Downloading Whisper model" local model_choice echo "" >&2 echo " Available models (from HuggingFace ggerganov/whisper.cpp):" >&2 echo " tiny.en — 75 MB (fastest, English only)" >&2 echo " base.en — 142 MB (good for quick tests)" >&2 echo " small.en — 466 MB (recommended for English) ★" >&2 echo " medium.en — 1.5 GB (high accuracy, English only)" >&2 echo " large-v3-turbo — 809 MB (best multilingual, fast)" >&2 echo " large-v3 — 2.9 GB (best accuracy, any language)" >&2 echo "" >&2 if [[ -n "${STT_MODEL:-}" && -f "${STT_MODEL}" ]]; then info "Using existing model: $STT_MODEL" else read -rp " Choose model [default=small.en]: " model_choice model_choice="${model_choice:-small.en}" local model_file="$MODELS_DIR/ggml-${model_choice}.bin" if [[ -f "$model_file" ]]; then ok "Model already downloaded: $model_file" else info "Downloading ggml-${model_choice}.bin (this may take a moment)..." curl -L --progress-bar \ -o "$model_file" \ "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${model_choice}.bin" \ || die "Failed to download model" ok "Downloaded: $model_file" fi STT_MODEL="$model_file" echo "STT_MODEL=$model_file" >> "$CONFIG_FILE" fi } setup_parakeet() { step "Installing parakeet.cpp" if command -v parakeet-cli &>/dev/null; then ok "parakeet-cli already available at $(command -v parakeet-cli)" else info "Checking for pre-built binary or Docker..." if command -v docker &>/dev/null; then ok "Docker available — parakeet.cpp will run via container" else die "parakeet-cli not found and Docker not installed. Please either: - Build parakeet.cpp: https://github.com/mudler/parakeet.cpp - Or install Docker for the container approach" fi fi step "Downloading Parakeet model" local model_choice echo "" >&2 echo " Available models (from HuggingFace mudler/parakeet-cpp-gguf):" >&2 echo " tdt_ctc-110m-q8_0 — 178 MB (fast, good English, smallest) ★" >&2 echo " tdt_ctc-110m-f16 — 268 MB (fast, lossless English)" >&2 echo " tdt-0.6b-v3-f16 — 1.4 GB (multilingual, recommended)" >&2 echo "" >&2 if [[ -n "${STT_MODEL:-}" && -f "${STT_MODEL}" ]]; then info "Using existing model: $STT_MODEL" else read -rp " Choose model [default=tdt_ctc-110m-q8_0]: " model_choice model_choice="${model_choice:-tdt_ctc-110m-q8_0}" local model_file="$MODELS_DIR/${model_choice}.gguf" if [[ -f "$model_file" ]]; then ok "Model already downloaded: $model_file" else info "Downloading ${model_choice}.gguf..." curl -L --progress-bar \ -o "$model_file" \ "https://huggingface.co/mudler/parakeet-cpp-gguf/resolve/main/${model_choice}.gguf" \ || die "Failed to download model" ok "Downloaded: $model_file" fi STT_MODEL="$model_file" echo "STT_MODEL=$model_file" >> "$CONFIG_FILE" fi } # ─── config display ───────────────────────────────────────────────── cmd_config() { load_config banner >&2 echo "" >&2 if [[ -f "$CONFIG_FILE" ]]; then echo " Saved config ($CONFIG_FILE):" >&2 echo "" >&2 while IFS='=' read -r key val; do printf " %-16s %s\n" "$key" "$val" >&2 done < "$CONFIG_FILE" else echo " (no saved config — run: ./meetings setup)" >&2 fi echo "" >&2 echo " Effective settings:" >&2 local stt_display="${STT_ENGINE:-$(detect_stt)}" stt_display="${stt_display:-(not set)}" echo " STT engine: $stt_display" >&2 echo " STT model: ${STT_MODEL:-(auto-detect)}" >&2 echo " LLM model: $OLLAMA_MODEL" >&2 echo " Ollama host: $OLLAMA_HOST" >&2 echo " Threads: $THREADS" >&2 echo " Language: $LANGUAGE" >&2 echo " Output dir: $OUTPUT_DIR" >&2 echo "" >&2 } # ─── convert audio ────────────────────────────────────────────────── convert_audio() { local input="$1" output="$2" info "Converting audio to 16kHz mono WAV..." ffmpeg -y -i "$input" -ar 16000 -ac 1 -c:a pcm_s16le "$output" \ -loglevel warning 2>/dev/null \ || die "ffmpeg conversion failed for $input" ok "Audio converted: $(du -h "$output" | cut -f1)" } # ─── transcribe with whisper ─────────────────────────────────────── transcribe_whisper() { local wav_file="$1" model="$2" binary="$3" info "Transcribing with whisper.cpp..." local lang_flag="" [[ "$LANGUAGE" != "auto" ]] && lang_flag="-l $LANGUAGE" local transcript transcript=$("$binary" \ -m "$model" \ -f "$wav_file" \ -t "$THREADS" \ $lang_flag \ -nt \ --no-prints \ 2>/dev/null) || die "Whisper transcription failed" echo "$transcript" } # ─── transcribe with parakeet ────────────────────────────────────── transcribe_parakeet() { local wav_file="$1" model="$2" binary="$3" info "Transcribing with parakeet.cpp..." local decoder_flag="" if echo "$model" | grep -qi "ctc"; then decoder_flag="--decoder ctc" elif echo "$model" | grep -qi "tdt"; then decoder_flag="--decoder tdt" fi if [[ -z "$binary" ]] && command -v docker &>/dev/null; then info "Using Docker for parakeet.cpp..." docker run --rm \ -v "$model:/model.gguf:ro" \ -v "$wav_file:/audio.wav:ro" \ ghcr.io/mudler/parakeet.cpp-cli:latest \ transcribe --model /model.gguf --input /audio.wav $decoder_flag 2>/dev/null \ || die "Docker parakeet transcription failed" else "$binary" transcribe --model "$model" --input "$wav_file" $decoder_flag 2>/dev/null \ || die "Parakeet transcription failed" fi } # ─── call Ollama ─────────────────────────────────────────────────── ollama_generate() { local prompt="$1" system="${2:-}" local json_payload if [[ -n "$system" ]]; then json_payload=$(jq -n \ --arg model "$OLLAMA_MODEL" \ --arg system "$system" \ --arg prompt "$prompt" \ '{model: $model, system: $system, prompt: $prompt, stream: false}') else json_payload=$(jq -n \ --arg model "$OLLAMA_MODEL" \ --arg prompt "$prompt" \ '{model: $model, prompt: $prompt, stream: false}') fi curl -sf "$OLLAMA_HOST/api/generate" \ -d "$json_payload" 2>/dev/null \ | jq -r '.response // empty' \ || die "Ollama request failed. Is the server running? (ollama serve)" } # ─── main pipeline ────────────────────────────────────────────────── cmd_process() { local input_file="$1" shift || true # Load config BEFORE flag parsing, so flags can override load_config while [[ $# -gt 0 ]]; do case "$1" in --stt) STT_ENGINE="$2"; shift 2 ;; --model) STT_MODEL="$2"; shift 2 ;; --llm) OLLAMA_MODEL="$2"; shift 2 ;; --lang) LANGUAGE="$2"; shift 2 ;; --threads) THREADS="$2"; shift 2 ;; --output) OUTPUT_DIR="$2"; shift 2 ;; *) die "Unknown option: $1. Run: ./meetings help" ;; esac done [[ -z "$input_file" ]] && die "Usage: ./meetings " [[ ! -f "$input_file" ]] && die "File not found: $input_file" STT_ENGINE="${STT_ENGINE:-$(detect_stt)}" [[ -z "$STT_ENGINE" ]] && die "No STT engine found. Run: ./meetings setup" local binary binary="$(get_stt_binary "$STT_ENGINE")" [[ -z "$binary" ]] && die "Could not find $STT_ENGINE binary. Run: ./meetings setup" local model if [[ -n "${STT_MODEL:-}" ]] && [[ -f "$STT_MODEL" ]]; then model="$STT_MODEL" else model="$(find_stt_model "$STT_ENGINE")" \ || die "No STT model found. Run: ./meetings setup" fi [[ ! -f "$model" ]] && die "Model file not found: $model" command -v ffmpeg &>/dev/null || die "ffmpeg not found. Install it first." curl -sf "$OLLAMA_HOST/api/tags" &>/dev/null \ || die "Ollama server not responding at $OLLAMA_HOST. Run: ollama serve" # ── create output directory ── local basename basename="$(date +%Y-%m-%d_%H%M)_$(basename "${input_file%.*}" | tr ' ' '_')" local outdir="$OUTPUT_DIR/$basename" mkdir -p "$outdir" banner >&2 echo " Input: $input_file" >&2 echo " STT engine: $STT_ENGINE" >&2 echo " STT model: $(basename "$model")" >&2 echo " LLM model: $OLLAMA_MODEL" >&2 echo " Language: $LANGUAGE" >&2 echo " Output: $outdir/" >&2 echo "" >&2 # ── step 1: convert ── step "Step 1/4 — Converting audio" local wav_file="$outdir/audio_16k.wav" convert_audio "$input_file" "$wav_file" # ── step 2: transcribe ── step "Step 2/4 — Transcribing with $STT_ENGINE" local transcript case "$STT_ENGINE" in whisper) transcript="$(transcribe_whisper "$wav_file" "$model" "$binary")" ;; parakeet) transcript="$(transcribe_parakeet "$wav_file" "$model" "$binary")" ;; *) die "Unknown engine: $STT_ENGINE" ;; esac if [[ -z "$transcript" || -z "$(echo "$transcript" | tr -d '[:space:]')" ]]; then die "Transcription produced empty output" fi echo "$transcript" > "$outdir/transcript.txt" local word_count word_count=$(echo "$transcript" | wc -w | tr -d ' ') ok "Transcript: $word_count words" # ── step 3: summarize ── step "Step 3/4 — Summarizing ($OLLAMA_MODEL)" local summarize_prompt summarize_prompt="Please provide a clear, concise summary of the following meeting transcript. Structure the summary with: - **Topic**: What the meeting was about - **Key Points**: Main topics discussed (3-5 bullet points) - **Decisions**: Any decisions that were made - **Open Questions**: Things left unresolved TRANSCRIPT: $transcript" local summary summary="$(ollama_generate "$summarize_prompt" "You are an expert meeting summarizer. Be concise but thorough. Always use markdown formatting.")" echo "$summary" > "$outdir/summary.md" ok "Summary saved" # ── step 4: action items ── step "Step 4/4 — Extracting action items ($OLLAMA_MODEL)" local actions_prompt actions_prompt="Extract all action items, tasks, and commitments from the following meeting transcript. For each action item, provide: - **Who**: The person responsible (or 'Unassigned' if unclear) - **What**: The specific task or action - **When**: Any deadline mentioned (or 'No deadline specified') - **Priority**: High / Medium / Low (based on urgency and context) Be thorough — capture every commitment, follow-up, and task mentioned or implied. Format as a numbered list. TRANSCRIPT: $transcript" local actions actions="$(ollama_generate "$actions_prompt" "You are an expert at extracting action items from meeting notes. Be thorough and specific. Always use markdown formatting.")" echo "$actions" > "$outdir/action_items.md" ok "Action items saved" # ── combine report ── cat > "$outdir/report.md" <&2 ok "All done! Files saved to: $outdir/" >&2 echo "" >&2 echo " 📄 Report: $outdir/report.md" >&2 echo " 📝 Transcript: $outdir/transcript.txt" >&2 echo " 📋 Summary: $outdir/summary.md" >&2 echo " ✅ Action Items: $outdir/action_items.md" >&2 echo "" >&2 echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2 echo "" >&2 head -50 "$outdir/report.md" | tail -35 >&2 echo "" >&2 echo "(showing preview — full report at $outdir/report.md)" >&2 } # ─── entry point ──────────────────────────────────────────────────── case "${1:-help}" in setup) shift; cmd_setup ;; doctor) load_config; cmd_doctor ;; config) cmd_config ;; help|--help|-h) banner >&2 cat <<'HELP' Usage: ./meetings Run the full pipeline ./meetings setup Install deps + download model ./meetings doctor Check all dependencies ./meetings config Show current configuration Options (override config): --stt STT engine to use --model Path to GGUF/GGML model file --llm Ollama model for summarization --lang Language code (default: en, auto for whisper) --threads Number of threads for STT --output Output directory (default: .) Environment variables: MEETINGS_DIR Config & models directory (default: ~/.meetings) MEETINGS_STT STT engine (whisper/parakeet) MEETINGS_STT_MODEL Path to model file MEETINGS_LLM Ollama model name (default: llama3.1:8b) MEETINGS_THREADS Thread count (default: 4) MEETINGS_LANG Language code (default: en) MEETINGS_OUTPUT Output directory (default: .) Examples: # First time — install everything ./meetings setup # Process a meeting recording ./meetings recording.mp3 # Use a different LLM model ./meetings meeting.wav --llm llama3.1:8b # Use parakeet with multilingual model ./meetings call.wav --stt parakeet --lang auto # Specify output directory ./meetings interview.m4a --output ./reports HELP ;; *) cmd_process "$@" ;; esac