#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" cd "${PROJECT_ROOT}" AVATAR_HOST="${AVATAR_HOST:-127.0.0.1}" AVATAR_PORT="${AVATAR_PORT:-7860}" AVATAR_LOG_DIR="${AVATAR_LOG_DIR:-${PROJECT_ROOT}/outputs/service_logs}" AVATAR_RUNS_ROOT="${AVATAR_RUNS_ROOT:-${PROJECT_ROOT}/outputs/interactive_avatar_runs}" AVATAR_WORKER_QUEUE_DIR="${AVATAR_WORKER_QUEUE_DIR:-${PROJECT_ROOT}/outputs/interactive_avatar_worker_queue}" AVATAR_WORKER_CUDA_VISIBLE_DEVICES="${AVATAR_WORKER_CUDA_VISIBLE_DEVICES:-0,1,2,3}" AVATAR_MASTER_PORT="${AVATAR_MASTER_PORT:-29731}" AVATAR_STARTUP_TIMEOUT="${AVATAR_STARTUP_TIMEOUT:-3600}" DIALOGUE_HOST="${DIALOGUE_HOST:-127.0.0.1}" DIALOGUE_PORT="${DIALOGUE_PORT:-7864}" DIALOGUE_MODEL_NAME="${DIALOGUE_MODEL_NAME:-taolive-dialogue}" DIALOGUE_STARTUP_TIMEOUT="${DIALOGUE_STARTUP_TIMEOUT:-600}" PYTHON_BIN="${PYTHON_BIN:-python}" MODEL_CKPT="${MODEL_CKPT:-}" BASE_MODEL_CKPT="${BASE_MODEL_CKPT:-}" GEMMA_PATH="${GEMMA_PATH:-}" DIALOGUE_MODEL_PATH="${DIALOGUE_MODEL_PATH:-${PROJECT_ROOT}/models/dialogue/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf}" PROJECT_LLAMA_SERVER_BIN="${PROJECT_ROOT}/third_party/llama.cpp/build/bin/llama-server" LLAMA_SERVER_BIN="${LLAMA_SERVER_BIN:-${PROJECT_LLAMA_SERVER_BIN}}" ASR_MODEL_PATH="${ASR_MODEL_PATH:-${PROJECT_ROOT}/models/whisper/tiny.pt}" WORKER_SESSION="taomate_worker" SERVICE_SESSION="taomate_service" DIALOGUE_SESSION="taomate_dialogue" for path in "${MODEL_CKPT}" "${BASE_MODEL_CKPT}" "${DIALOGUE_MODEL_PATH}" "${ASR_MODEL_PATH}"; do if [ ! -f "${path}" ]; then echo "Required model file not found: ${path:-}" >&2 exit 2 fi done if [ ! -d "${GEMMA_PATH}" ]; then echo "GEMMA_PATH must point to the local text-encoder directory." >&2 exit 2 fi for command in tmux curl; do if ! command -v "${command}" >/dev/null 2>&1; then echo "${command} is required to start the demo stack." >&2 exit 2 fi done if ! "${PYTHON_BIN}" -c "import whisper" >/dev/null 2>&1; then echo "Python package openai-whisper is required. Run: ${PYTHON_BIN} -m pip install -r requirements.txt" >&2 exit 2 fi IFS=',' read -r -a WORKER_GPU_IDS <<< "${AVATAR_WORKER_CUDA_VISIBLE_DEVICES}" if [ "${#WORKER_GPU_IDS[@]}" -ne 4 ]; then echo "AVATAR_WORKER_CUDA_VISIBLE_DEVICES must contain exactly four GPU IDs." >&2 exit 2 fi for gpu_id in "${WORKER_GPU_IDS[@]}"; do if [[ ! "${gpu_id}" =~ ^[0-9]+$ ]]; then echo "Invalid GPU ID in AVATAR_WORKER_CUDA_VISIBLE_DEVICES: ${gpu_id}" >&2 exit 2 fi done if [[ ! "${AVATAR_STARTUP_TIMEOUT}" =~ ^[1-9][0-9]*$ ]]; then echo "AVATAR_STARTUP_TIMEOUT must be a positive integer." >&2 exit 2 fi if [[ ! "${DIALOGUE_STARTUP_TIMEOUT}" =~ ^[1-9][0-9]*$ ]]; then echo "DIALOGUE_STARTUP_TIMEOUT must be a positive integer." >&2 exit 2 fi DIALOGUE_GPU="${DIALOGUE_GPU:-${WORKER_GPU_IDS[3]}}" ASR_DEVICE="${ASR_DEVICE:-cuda:${DIALOGUE_GPU}}" DIALOGUE_API_BASE="http://${DIALOGUE_HOST}:${DIALOGUE_PORT}/v1" ACCESS_HOST="${AVATAR_HOST}" if [ "${ACCESS_HOST}" = "0.0.0.0" ]; then ACCESS_HOST="127.0.0.1" fi DEMO_URL="http://${ACCESS_HOST}:${AVATAR_PORT}" HEARTBEAT_PATH="${AVATAR_WORKER_QUEUE_DIR}/worker_heartbeat.json" resolve_llama_server_bin() { local configured_bin="$1" local compute_cap="" local binary_sm="" local target_sm="" local sibling_bin="" if [ ! -x "${configured_bin}" ]; then echo "LLAMA_SERVER_BIN must point to an executable llama-server: ${configured_bin}" >&2 return 2 fi if ! command -v nvidia-smi >/dev/null 2>&1; then printf '%s\n' "${configured_bin}" return 0 fi compute_cap="$(nvidia-smi -i "${DIALOGUE_GPU}" --query-gpu=compute_cap --format=csv,noheader 2>/dev/null || true)" compute_cap="${compute_cap%%$'\n'*}" compute_cap="${compute_cap//[[:space:]]/}" target_sm="${compute_cap//./}" if [[ -z "${target_sm}" || ! "${configured_bin}" =~ sm([0-9]+) ]]; then printf '%s\n' "${configured_bin}" return 0 fi binary_sm="${BASH_REMATCH[1]}" if [ "${binary_sm}" = "${target_sm}" ]; then printf '%s\n' "${configured_bin}" return 0 fi sibling_bin="${configured_bin/sm${binary_sm}/sm${target_sm}}" if [ -x "${sibling_bin}" ]; then echo "llama-server targets sm${binary_sm}, but dialogue GPU ${DIALOGUE_GPU} is sm${target_sm}; using ${sibling_bin}." >&2 printf '%s\n' "${sibling_bin}" return 0 fi if [ -x "${PROJECT_LLAMA_SERVER_BIN}" ] && [ "${PROJECT_LLAMA_SERVER_BIN}" != "${configured_bin}" ]; then echo "llama-server targets sm${binary_sm}, but dialogue GPU ${DIALOGUE_GPU} is sm${target_sm}; using the project-local build." >&2 printf '%s\n' "${PROJECT_LLAMA_SERVER_BIN}" return 0 fi echo "LLAMA_SERVER_BIN targets sm${binary_sm}, but dialogue GPU ${DIALOGUE_GPU} requires sm${target_sm}. Build llama.cpp on this machine or provide a compatible binary." >&2 return 2 } LLAMA_SERVER_BIN="$(resolve_llama_server_bin "${LLAMA_SERVER_BIN}")" mkdir -p "${AVATAR_LOG_DIR}" "${AVATAR_RUNS_ROOT}" "${AVATAR_WORKER_QUEUE_DIR}"/{pending,running,done,failed} rm -f "${HEARTBEAT_PATH}" for session in "${SERVICE_SESSION}" "${WORKER_SESSION}" "${DIALOGUE_SESSION}"; do tmux kill-session -t "${session}" 2>/dev/null || true done start_dialogue_session() { tmux kill-session -t "${DIALOGUE_SESSION}" 2>/dev/null || true tmux new-session -d -s "${DIALOGUE_SESSION}" \ "cd '${PROJECT_ROOT}' && env DIALOGUE_HOST='${DIALOGUE_HOST}' DIALOGUE_PORT='${DIALOGUE_PORT}' DIALOGUE_MODEL_NAME='${DIALOGUE_MODEL_NAME}' DIALOGUE_GPU='${DIALOGUE_GPU}' DIALOGUE_MODEL_PATH='${DIALOGUE_MODEL_PATH}' LLAMA_SERVER_BIN='${LLAMA_SERVER_BIN}' bash scripts/inference/start_dialogue_server.sh 2>&1 | tee -a '${AVATAR_LOG_DIR}/dialogue.log'" } wait_for_dialogue() { local started_at="$(date +%s)" local now="" local elapsed="" while true; do if curl -fsS --max-time 2 "${DIALOGUE_API_BASE}/models" >/dev/null 2>&1; then return 0 fi if ! tmux has-session -t "${DIALOGUE_SESSION}" 2>/dev/null; then return 1 fi now="$(date +%s)" elapsed="$((now - started_at))" printf '[%3ss] Dialogue: loading\n' "${elapsed}" if [ "${elapsed}" -ge "${DIALOGUE_STARTUP_TIMEOUT}" ]; then return 1 fi sleep 30 done } print_stop_commands() { echo " bash scripts/inference/stop_interactive_avatar_stack.sh" echo " # or: tmux kill-session -t ${SERVICE_SESSION}" echo " # tmux kill-session -t ${WORKER_SESSION}" echo " # tmux kill-session -t ${DIALOGUE_SESSION}" } show_failure_logs() { for name in dialogue worker service; do echo "${name^} log: ${AVATAR_LOG_DIR}/${name}.log" >&2 tail -n 30 "${AVATAR_LOG_DIR}/${name}.log" 2>/dev/null >&2 || true done } on_interrupt() { echo echo "Startup wait interrupted. The tmux services are still running." echo "Stop them with:" print_stop_commands exit 130 } trap on_interrupt INT TERM echo "TaoMate interactive demo" echo "[1/4] Starting the dialogue model on GPU ${DIALOGUE_GPU}" start_dialogue_session if ! wait_for_dialogue; then echo "Dialogue service failed before becoming ready." >&2 show_failure_logs echo "No worker or web service was started." >&2 exit 1 fi echo "Dialogue model is ready." echo "[2/4] Starting the four-GPU resident worker on ${AVATAR_WORKER_CUDA_VISIBLE_DEVICES}" tmux new-session -d -s "${WORKER_SESSION}" \ "cd '${PROJECT_ROOT}' && env PYTHON_BIN='${PYTHON_BIN}' AVATAR_WORKER_QUEUE_DIR='${AVATAR_WORKER_QUEUE_DIR}' AVATAR_WORKER_CUDA_VISIBLE_DEVICES='${AVATAR_WORKER_CUDA_VISIBLE_DEVICES}' AVATAR_MASTER_PORT='${AVATAR_MASTER_PORT}' bash scripts/inference/start_interactive_avatar_worker.sh 2>&1 | tee -a '${AVATAR_LOG_DIR}/worker.log'" echo "[3/4] Starting the web service on ${AVATAR_HOST}:${AVATAR_PORT}" tmux new-session -d -s "${SERVICE_SESSION}" \ "cd '${PROJECT_ROOT}' && env PYTHON_BIN='${PYTHON_BIN}' MODEL_CKPT='${MODEL_CKPT}' BASE_MODEL_CKPT='${BASE_MODEL_CKPT}' GEMMA_PATH='${GEMMA_PATH}' ASR_MODEL_PATH='${ASR_MODEL_PATH}' ASR_DEVICE='${ASR_DEVICE}' DIALOGUE_API_BASE='${DIALOGUE_API_BASE}' DIALOGUE_MODEL_NAME='${DIALOGUE_MODEL_NAME}' AVATAR_HOST='${AVATAR_HOST}' AVATAR_PORT='${AVATAR_PORT}' AVATAR_RUNS_ROOT='${AVATAR_RUNS_ROOT}' AVATAR_WORKER_QUEUE_DIR='${AVATAR_WORKER_QUEUE_DIR}' bash scripts/inference/start_interactive_avatar_service.sh 2>&1 | tee -a '${AVATAR_LOG_DIR}/service.log'" echo "[4/4] Waiting for the dialogue model, resident model, and website" STARTED_AT="$(date +%s)" while true; do NOW="$(date +%s)" ELAPSED="$((NOW - STARTED_AT))" for session in "${DIALOGUE_SESSION}" "${WORKER_SESSION}" "${SERVICE_SESSION}"; do if ! tmux has-session -t "${session}" 2>/dev/null; then echo "Startup failed: tmux session ${session} exited." >&2 show_failure_logs echo "Stop remaining services with:" >&2 print_stop_commands >&2 exit 1 fi done DIALOGUE_STATE="loading" if curl -fsS --max-time 2 "${DIALOGUE_API_BASE}/models" >/dev/null 2>&1; then DIALOGUE_STATE="ready" fi READY_JSON="$(curl -fsS --max-time 3 "${DEMO_URL}/readyz" 2>/dev/null || true)" WEB_STATE="starting" if [ -n "${READY_JSON}" ]; then WEB_STATE="online" fi WORKER_STATE="starting" MODEL_STATE="loading" if [ -f "${HEARTBEAT_PATH}" ]; then HEARTBEAT_STATE="$("${PYTHON_BIN}" -c 'import json, sys; data=json.load(open(sys.argv[1], encoding="utf-8")); residency=str(data.get("model_residency", "")); print("{}|{}".format(data.get("status", "starting"), "ready" if residency in {"resident_runtime_cache_loaded", "loaded", "warm"} else "loading"))' "${HEARTBEAT_PATH}" 2>/dev/null || true)" if [ -n "${HEARTBEAT_STATE}" ]; then WORKER_STATE="${HEARTBEAT_STATE%%|*}" MODEL_STATE="${HEARTBEAT_STATE#*|}" fi fi printf '[%3ss] Dialogue: %-7s Web: %-8s Worker: %-14s Model: %s\n' \ "${ELAPSED}" "${DIALOGUE_STATE}" "${WEB_STATE}" "${WORKER_STATE}" "${MODEL_STATE}" if [ "${DIALOGUE_STATE}" = "ready" ] && \ [[ "${READY_JSON}" == *'"ready":true'* ]] && \ curl -fsS --max-time 2 "${DEMO_URL}/" >/dev/null 2>&1; then trap - INT TERM echo echo "TaoMate demo is ready." echo "Open: ${DEMO_URL}/" echo "Logs: ${AVATAR_LOG_DIR}" echo echo "Stop the demo with:" print_stop_commands exit 0 fi if [ "${ELAPSED}" -ge "${AVATAR_STARTUP_TIMEOUT}" ]; then echo "Startup timed out after ${AVATAR_STARTUP_TIMEOUT}s." >&2 show_failure_logs echo "The tmux services are still running. Stop them with:" >&2 print_stop_commands >&2 exit 1 fi sleep 30 done