File size: 18,369 Bytes
2c57606
22d2fca
2c57606
 
 
 
 
 
 
 
 
 
 
 
 
22d2fca
2c57606
7ddb9b6
 
 
22d2fca
 
 
 
 
 
2c57606
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22d2fca
2c57606
 
 
 
7ddb9b6
22d2fca
 
2c57606
 
7ddb9b6
2c57606
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22d2fca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2c57606
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ddb9b6
 
 
 
 
 
 
 
 
 
 
 
 
 
22d2fca
 
 
 
 
 
 
 
 
7ddb9b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22d2fca
 
7ddb9b6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22d2fca
 
7ddb9b6
 
 
 
 
 
 
 
 
 
 
 
 
 
2c57606
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22d2fca
 
2c57606
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22d2fca
 
 
 
 
2c57606
 
 
 
7ddb9b6
 
 
 
 
 
 
 
 
 
 
 
 
2c57606
 
 
7ddb9b6
 
 
 
 
 
 
22d2fca
 
 
 
 
 
 
 
 
 
 
 
7ddb9b6
 
 
 
 
 
2c57606
 
 
7ddb9b6
 
 
2c57606
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22d2fca
2c57606
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
#!/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