#!/usr/bin/env bash # Start AX Meeting Studio on one AX650 board or AX8850 AXCL host. This launcher deliberately # separates immutable deployment checks from the optional first-run bootstrap. set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PYTHON_BIN="${PYTHON_BIN:-python3}" INSTALL_DEPS="${AXMEETING_INSTALL_DEPS:-AUTO}" INSTALL_WHEEL="${AXMEETING_INSTALL_WHEEL:-AUTO}" BUILD_IF_MISSING="${AXMEETING_BUILD_IF_MISSING:-OFF}" VERIFY_TLS="${AXMEETING_VERIFY_TLS:-ON}" BIND_HOST="${HOST:-0.0.0.0}" PORT="${PORT:-8000}" PUBLIC_HOST="${AXMEETING_PUBLIC_HOST:-}" ASR_BACKEND="${ASR_BACKEND:-sensevoice}" ACCELERATOR="${AXMEETING_ACCELERATOR:-auto}" MODEL_DIR="${AX_MODEL_DIR:-${ROOT_DIR}/models}" # On the target board the BSP msp/out payload is mounted at /soc. Keep this # configurable for deployments that put the BSP somewhere else. BSP_MSP_DIR="${AXMEETING_BSP_MSP_DIR:-/soc}" AXCL_DIR="${AXMEETING_AXCL_DIR:-}" if [[ -n "${AXCL_DIR}" ]]; then AXCL_LIBRARY_DIR="${AXCL_DIR}/lib" else AXCL_LIBRARY_DIR="/usr/lib/axcl" fi CERT_DIR="${ROOT_DIR}/certs" STATE_ROOT="${AXMEETING_STATE_DIR:-${ROOT_DIR}/.runtime}" RUNTIME_REQUIREMENTS="${ROOT_DIR}/requirements-runtime.txt" export SSL_CERT="${SSL_CERT:-${CERT_DIR}/cert.pem}" export SSL_KEY="${SSL_KEY:-${CERT_DIR}/key.pem}" # Progress polling is frequent. Keep the board console focused on errors by # default; these values can be overridden while diagnosing a deployment. export AXMEETING_UVICORN_LOG_LEVEL="${AXMEETING_UVICORN_LOG_LEVEL:-warning}" export AXMEETING_ACCESS_LOG="${AXMEETING_ACCESS_LOG:-OFF}" export AXMEETING_AX_LOG_LEVEL="${AXMEETING_AX_LOG_LEVEL:-WARN}" export PYTHONUNBUFFERED=1 export PIP_DISABLE_PIP_VERSION_CHECK=1 usage() { cat <<'EOF' Usage: ./run_web_studio.sh [--port PORT] [--check] Required deployment assets: - external models/ root (both SenseVoice and FireRed/Punc because the Web UI exposes both backends) - AX650 BSP msp/out, or the AXCL runtime library - a browser-trusted HTTPS certificate and matching private key Useful environment variables: AX_MODEL_DIR=/opt/ax-meeting/models AXMEETING_BSP_MSP_DIR=/soc # BSP msp/out (include/ and lib/) AXMEETING_ACCELERATOR=auto|ax650|axcl AXMEETING_AXCL_DIR=/opt/axcl # custom prefix (lib/libaxcl_rt.so) HOST=0.0.0.0 PORT=8000 AXMEETING_PUBLIC_HOST=10.0.0.20 SSL_CERT=/path/cert.pem SSL_KEY=/path/key.pem AXMEETING_WHEEL=/path/to/compatible.whl # optional; otherwise auto-select AXMEETING_BUILD_IF_MISSING=ON # explicit first-board bootstrap AXMEETING_INSTALL_DEPS=ON|OFF|AUTO AXMEETING_INSTALL_WHEEL=ON|OFF|AUTO AXMEETING_VERIFY_TLS=ON|OFF --port, -p PORT # overrides PORT for this launch --check # validate only; never install or start EOF } normalize_toggle() { local name="$1" local value="${2^^}" case "${value}" in ON|OFF|AUTO) printf '%s\n' "${value}" ;; *) echo "ERROR: ${name} must be AUTO, ON, or OFF; got: ${2}" >&2 exit 2 ;; esac } ax650_runtime_available() { [[ -d "${BSP_MSP_DIR}/include" && -d "${BSP_MSP_DIR}/lib" ]] || return 1 local library for library in libax_sys.so libax_engine.so libax_interpreter.so libax_dmadim.so; do [[ -r "${BSP_MSP_DIR}/lib/${library}" ]] || return 1 done } axcl_runtime_available() { [[ -r "${AXCL_LIBRARY_DIR}/libaxcl_rt.so" || -r "${AXCL_LIBRARY_DIR}/libaxcl_rt.so.1" ]] } select_accelerator() { case "${ACCELERATOR}" in ax650|axcl) ;; auto) if axcl_runtime_available && ! ax650_runtime_available; then ACCELERATOR=axcl elif ax650_runtime_available && ! axcl_runtime_available; then ACCELERATOR=ax650 elif axcl_runtime_available && ax650_runtime_available; then echo "ERROR: both AX650 and AXCL runtimes are available; set AXMEETING_ACCELERATOR=ax650 or axcl." >&2 exit 2 else echo "ERROR: no supported AX runtime was found." >&2 echo "Expected AX650 BSP at ${BSP_MSP_DIR} or AXCL runtime at ${AXCL_LIBRARY_DIR}." >&2 exit 1 fi ;; *) echo "ERROR: AXMEETING_ACCELERATOR must be auto, ax650, or axcl; got: ${ACCELERATOR}" >&2 exit 2 ;; esac if [[ "${ACCELERATOR}" == axcl ]] && ! axcl_runtime_available; then echo "ERROR: AXCL runtime libaxcl_rt.so is missing from ${AXCL_LIBRARY_DIR}." >&2 echo "Install the AXCL runtime package or set AXMEETING_AXCL_DIR." >&2 exit 1 fi if [[ "${ACCELERATOR}" == ax650 ]] && ! ax650_runtime_available; then echo "ERROR: AX650 BSP msp/out is incomplete at ${BSP_MSP_DIR}." >&2 echo "Set AXMEETING_BSP_MSP_DIR to the matching BSP path." >&2 exit 1 fi } runtime_dependencies_available() { "${PYTHON_BIN}" - <<'PY' import importlib.util import sys modules = ( "numpy", "sentencepiece", "scipy", "sklearn", "fastcluster", "fastapi", "uvicorn", "websockets", "openai", "soundfile", "multipart", ) missing = [module for module in modules if importlib.util.find_spec(module) is None] if missing: print("Missing Python runtime modules: " + ", ".join(missing), file=sys.stderr) raise SystemExit(1) PY } installed_web_package_available() { "${PYTHON_BIN}" -c 'import ax_meeting_studio.web.server' >/dev/null 2>&1 } wheel_digest() { "${PYTHON_BIN}" - "$1" <<'PY' import hashlib import sys digest = hashlib.sha256() with open(sys.argv[1], "rb") as source: for block in iter(lambda: source.read(1024 * 1024), b""): digest.update(block) print(digest.hexdigest()) PY } # Print the best wheel that can be installed by the current interpreter. A # deployment directory may contain wheels for several CPython versions and # platforms; incompatible files are intentionally ignored. packaging is a # pip dependency on supported Python installations, while the small fallback # keeps the launcher useful when only the standard library is available. select_compatible_wheel() { "${PYTHON_BIN}" - "$@" <<'WHEELPY' import os import platform import sys paths = sys.argv[1:] if not paths: raise SystemExit(1) backend = os.environ.get("AXMEETING_ACCELERATOR", "auto").lower() def backend_compatible(path, version_text=None): text = (version_text or os.path.basename(path)).lower() if backend == "axcl": return "+axcl" in text if backend == "ax650": return "+axcl" not in text return True def fallback_tags(): major, minor = sys.version_info[:2] implementation = platform.python_implementation() if implementation == "CPython": python_tag = f"cp{major}{minor}" abi_tags = (python_tag, "abi3", "none") else: python_tag = f"py{major}" abi_tags = ("none",) machine = platform.machine().lower() platform_tags = { "aarch64": ("linux_aarch64", "manylinux2014_aarch64"), "arm64": ("linux_aarch64", "manylinux2014_aarch64"), "x86_64": ("linux_x86_64", "manylinux2014_x86_64"), "amd64": ("linux_x86_64", "manylinux2014_x86_64"), "armv7l": ("linux_armv7l", "manylinux2014_armv7l"), }.get(machine, (f"linux_{machine}",)) return { f"{py}-{abi}-{plat}" for py in (python_tag, "py3", "py2", "py") for abi in abi_tags for plat in platform_tags } | {"py3-none-any", "py-none-any"} try: from packaging.tags import sys_tags from packaging.utils import parse_wheel_filename except ImportError: supported = fallback_tags() parsed = [] for path in paths: if not backend_compatible(path): continue fields = os.path.basename(path)[:-4].split("-") if len(fields) < 5: continue for py in fields[-3].split("."): for abi in fields[-2].split("."): for plat in fields[-1].split("."): if f"{py}-{abi}-{plat}" in supported: parsed.append((0, os.path.basename(path), path)) break else: continue break else: continue break if parsed: print(max(parsed)[2]) raise SystemExit(0) raise SystemExit(1) supported = {tag: index for index, tag in enumerate(sys_tags())} compatible = [] for path in paths: try: _, version, build, tags = parse_wheel_filename(os.path.basename(path)) except Exception: continue if not backend_compatible(path, str(version)): continue ranks = [supported[tag] for tag in tags if tag in supported] if ranks: compatible.append((min(ranks), version, build, os.path.basename(path), path)) if not compatible: raise SystemExit(1) best_rank = min(item[0] for item in compatible) best = max((item for item in compatible if item[0] == best_rank), key=lambda item: (item[1], item[2], item[3])) print(best[4]) WHEELPY } display_host() { if [[ -n "${PUBLIC_HOST}" ]]; then printf '%s\n' "${PUBLIC_HOST}" elif [[ "${BIND_HOST}" == "0.0.0.0" || "${BIND_HOST}" == "::" ]]; then "${PYTHON_BIN}" - <<'PY' import socket try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as connection: connection.connect(("8.8.8.8", 80)) print(connection.getsockname()[0]) except OSError: print("127.0.0.1") PY else printf '%s\n' "${BIND_HOST}" fi } ensure_tls() { if [[ "${VERIFY_TLS}" == "OFF" ]]; then return fi if ! command -v openssl >/dev/null 2>&1; then echo "ERROR: openssl is required for TLS validation; set AXMEETING_VERIFY_TLS=OFF only for diagnostics." >&2 exit 1 fi if ! openssl x509 -in "${SSL_CERT}" -noout >/dev/null 2>&1; then echo "ERROR: SSL_CERT is not a readable X.509 certificate: ${SSL_CERT}" >&2 exit 1 fi if ! openssl pkey -in "${SSL_KEY}" -noout >/dev/null 2>&1; then echo "ERROR: SSL_KEY is not a readable private key: ${SSL_KEY}" >&2 exit 1 fi if ! openssl x509 -in "${SSL_CERT}" -noout -checkend 0 >/dev/null 2>&1; then echo "ERROR: HTTPS certificate is expired: ${SSL_CERT}" >&2 exit 1 fi if ! cmp -s \ <(openssl x509 -in "${SSL_CERT}" -pubkey -noout | openssl pkey -pubin -pubout) \ <(openssl pkey -in "${SSL_KEY}" -pubout); then echo "ERROR: SSL_CERT and SSL_KEY do not form a matching key pair." >&2 exit 1 fi } ensure_runtime_dependencies() { if runtime_dependencies_available; then return fi if [[ "${INSTALL_DEPS}" == "OFF" || "${CHECK_ONLY}" == true ]]; then echo "ERROR: Python runtime dependencies are missing; preflight will not install them." >&2 echo "Install ${RUNTIME_REQUIREMENTS} with ${PYTHON_BIN}, then retry." >&2 exit 1 fi echo "Installing missing Python runtime dependencies..." "${PYTHON_BIN}" -m pip install --user -r "${RUNTIME_REQUIREMENTS}" runtime_dependencies_available } CHECK_ONLY=false while (( $# > 0 )); do case "$1" in --help|-h) usage exit 0 ;; --check) CHECK_ONLY=true shift ;; --port|-p) if (( $# < 2 )); then echo "ERROR: $1 requires a port number." >&2 usage >&2 exit 2 fi PORT="$2" shift 2 ;; --port=*) PORT="${1#--port=}" shift ;; *) echo "ERROR: unsupported argument: $1" >&2 usage >&2 exit 2 ;; esac done INSTALL_DEPS="$(normalize_toggle AXMEETING_INSTALL_DEPS "${INSTALL_DEPS}")" INSTALL_WHEEL="$(normalize_toggle AXMEETING_INSTALL_WHEEL "${INSTALL_WHEEL}")" BUILD_IF_MISSING="$(normalize_toggle AXMEETING_BUILD_IF_MISSING "${BUILD_IF_MISSING}")" VERIFY_TLS="$(normalize_toggle AXMEETING_VERIFY_TLS "${VERIFY_TLS}")" [[ "${BUILD_IF_MISSING}" == "AUTO" ]] && BUILD_IF_MISSING="OFF" [[ "${VERIFY_TLS}" == "AUTO" ]] && VERIFY_TLS="ON" case "${ASR_BACKEND}" in sensevoice|firered_punc) ;; *) echo "ERROR: ASR_BACKEND must be sensevoice or firered_punc, got: ${ASR_BACKEND}" >&2 exit 2 ;; esac if [[ ! "${PORT}" =~ ^[0-9]+$ ]] || (( PORT < 1 || PORT > 65535 )); then echo "ERROR: PORT must be an integer between 1 and 65535, got: ${PORT}" >&2 exit 2 fi select_accelerator export AXMEETING_ACCELERATOR="${ACCELERATOR}" if [[ ! -r "${SSL_CERT}" || ! -r "${SSL_KEY}" ]]; then echo "ERROR: HTTPS certificate or key is missing." >&2 echo "Expected SSL_CERT=${SSL_CERT} and SSL_KEY=${SSL_KEY}" >&2 exit 1 fi ensure_tls if [[ ! -d "${MODEL_DIR}" ]]; then echo "ERROR: AX_MODEL_DIR does not exist: ${MODEL_DIR}" >&2 exit 1 fi MODEL_DIR="$(cd "${MODEL_DIR}" && pwd -P)" # The Web Studio exposes both choices, so validating only ASR_BACKEND would # allow a later UI switch to fail after the user has already submitted audio. if ! "${PYTHON_BIN}" "${ROOT_DIR}/tools/verify_assets.py" \ --manifest "${ROOT_DIR}/src/ax_meeting_cpp/resources/model_assets.json" \ --root "${MODEL_DIR}"; then echo "Fix: deploy all VAD, CAM++, SenseVoice, FireRed AED, and Punc assets under AX_MODEL_DIR." >&2 exit 1 fi export AX_MODEL_DIR="${MODEL_DIR}" export ASR_BACKEND export HOST="${BIND_HOST}" export PORT if [[ "${ACCELERATOR}" == axcl ]]; then export LD_LIBRARY_PATH="${AXCL_LIBRARY_DIR}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" else export LD_LIBRARY_PATH="${BSP_MSP_DIR}/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" fi ensure_runtime_dependencies WHEEL_PATH="${AXMEETING_WHEEL:-}" wheel_candidates=() if [[ -n "${WHEEL_PATH}" ]]; then if [[ ! -f "${WHEEL_PATH}" ]]; then echo "ERROR: AXMEETING_WHEEL does not exist: ${WHEEL_PATH}" >&2 exit 1 fi if ! selected_wheel="$(select_compatible_wheel "${WHEEL_PATH}")"; then echo "ERROR: AXMEETING_WHEEL is not compatible with ${PYTHON_BIN}: ${WHEEL_PATH}" >&2 echo "Choose a wheel matching the current Python/ABI/platform, or unset AXMEETING_WHEEL for auto-selection." >&2 exit 1 fi WHEEL_PATH="${selected_wheel}" else shopt -s nullglob wheel_candidates=("${ROOT_DIR}"/wheel/ax_meeting_studio-*.whl) shopt -u nullglob if ! WHEEL_PATH="$(select_compatible_wheel "${wheel_candidates[@]}")"; then if (( ${#wheel_candidates[@]} == 0 )); then echo "No AX Meeting wheel was found under ${ROOT_DIR}/wheel/." >&2 else echo "No wheel under ${ROOT_DIR}/wheel/ is compatible with ${PYTHON_BIN}; incompatible wheels were ignored." >&2 fi if [[ "${BUILD_IF_MISSING}" == "ON" && "${CHECK_ONLY}" != true ]]; then if [[ "${ACCELERATOR}" == axcl ]]; then BUILD_SCRIPT="${AXMEETING_BUILD_SCRIPT:-${ROOT_DIR}/build_ax8850_aarch64_wheel.sh}" else BUILD_SCRIPT="${AXMEETING_BUILD_SCRIPT:-${ROOT_DIR}/build_ax650_wheel.sh}" fi if [[ ! -x "${BUILD_SCRIPT}" ]]; then echo "ERROR: AXMEETING_BUILD_IF_MISSING=ON but build script is unavailable: ${BUILD_SCRIPT}" >&2 echo "Copy a wheel into ${ROOT_DIR}/wheel or set AXMEETING_BUILD_SCRIPT." >&2 exit 1 fi echo "Wheel is absent or incompatible; running ${BUILD_SCRIPT}..." "${BUILD_SCRIPT}" shopt -s nullglob wheel_candidates=("${ROOT_DIR}"/wheel/ax_meeting_studio-*.whl) shopt -u nullglob if ! WHEEL_PATH="$(select_compatible_wheel "${wheel_candidates[@]}")"; then WHEEL_PATH="" fi fi fi fi if [[ -z "${WHEEL_PATH}" || ! -f "${WHEEL_PATH}" ]]; then echo "ERROR: no compatible AX Meeting wheel is available for ${PYTHON_BIN}." >&2 echo "Build or copy a matching wheel into ${ROOT_DIR}/wheel/, or set AXMEETING_WHEEL=/path/to/compatible.whl." >&2 exit 1 fi PYTHON_TAG="$("${PYTHON_BIN}" -c 'import sys; print(f"cp{sys.version_info.major}{sys.version_info.minor}")')" WHEEL_MARKER="${STATE_ROOT}/${PYTHON_TAG}/installed-wheel.sha256" WHEEL_DIGEST="$(wheel_digest "${WHEEL_PATH}")" INSTALLED_DIGEST="" if [[ -r "${WHEEL_MARKER}" ]]; then read -r INSTALLED_DIGEST < "${WHEEL_MARKER}" || true fi needs_wheel_install=false if [[ "${INSTALL_WHEEL}" == "ON" ]]; then needs_wheel_install=true elif [[ "${INSTALL_WHEEL}" == "AUTO" ]] && { [[ "${INSTALLED_DIGEST:-}" != "${WHEEL_DIGEST}" ]] || ! installed_web_package_available; }; then needs_wheel_install=true fi if [[ "${INSTALL_WHEEL}" == "OFF" ]] && ! installed_web_package_available; then echo "ERROR: ax-meeting-studio is unavailable and AXMEETING_INSTALL_WHEEL=OFF." >&2 exit 1 fi if [[ "${CHECK_ONLY}" == true && "${needs_wheel_install}" == true ]]; then echo "ERROR: current wheel is not installed for ${PYTHON_BIN}; preflight will not modify Python packages." >&2 echo "Run ./run_web_studio.sh once, or set AXMEETING_INSTALL_WHEEL=ON." >&2 exit 1 fi if [[ "${needs_wheel_install}" == true ]]; then echo "Installing current AX Meeting wheel..." "${PYTHON_BIN}" -m pip install --user --force-reinstall --no-deps "${WHEEL_PATH}" mkdir -p "$(dirname "${WHEEL_MARKER}")" printf '%s\n' "${WHEEL_DIGEST}" > "${WHEEL_MARKER}.tmp" mv "${WHEEL_MARKER}.tmp" "${WHEEL_MARKER}" fi if ! installed_web_package_available; then echo "ERROR: ax-meeting-studio cannot be imported by ${PYTHON_BIN} after installation." >&2 exit 1 fi if [[ "${CHECK_ONLY}" == true ]]; then echo "AX Meeting Studio preflight passed." exit 0 fi DISPLAY_HOST="$(display_host)" echo echo "AX Meeting Studio" echo " URL: https://${DISPLAY_HOST}:${PORT}" echo " Bind: ${BIND_HOST}:${PORT} (single ${ACCELERATOR} worker)" echo " Backends: SenseVoice + FireRed AED/Punc" echo " Models: ${AX_MODEL_DIR}" echo " Wheel: ${WHEEL_PATH}" echo " Logs: uvicorn=${AXMEETING_UVICORN_LOG_LEVEL}, access=${AXMEETING_ACCESS_LOG}, ax=${AXMEETING_AX_LOG_LEVEL}" echo exec "${PYTHON_BIN}" -m ax_meeting_studio.web.server