Files
ledex-demo/meetings-cli/meetings
T

659 lines
23 KiB
Bash
Executable File

#!/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 <audio_file> # 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 <audio_file>' 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 <audio_file>"
[[ ! -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" <<REPORT
# Meeting Report
**Date:** $(date '+%B %d, %Y at %H:%M')
**Source:** $(basename "$input_file")
**STT:** $STT_ENGINE | **LLM:** $OLLAMA_MODEL
---
## Summary
$summary
---
## Action Items
$actions
---
## Full Transcript
$transcript
REPORT
echo "" >&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 <audio_file> Run the full pipeline
./meetings setup Install deps + download model
./meetings doctor Check all dependencies
./meetings config Show current configuration
Options (override config):
--stt <whisper|parakeet> STT engine to use
--model <path> Path to GGUF/GGML model file
--llm <ollama_model> Ollama model for summarization
--lang <code> Language code (default: en, auto for whisper)
--threads <N> Number of threads for STT
--output <dir> 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