diff --git a/CHANGELOG.md b/CHANGELOG.md index 469bca6..9c19512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -562,3 +562,11 @@ - `huobao-drama` on `:5678` - Extended `deploy_fnos_storyforge_lan_stack.sh` so the NAS LAN stack can recreate model gateway, n8n, huobao, live recorder, collector and web from repo-managed assets. - Switched collector fnOS defaults away from the Mac host for `LOCAL_OPENAI_BASE_URL`, `N8N_BASE_URL`, and `HUOBAO_BASE_URL`, so the NAS stack no longer depends on local disk-hosted services for those routes. +# 2026-04-06 + +## 公网模型 / Windows ASR 收口 + +- 默认不再为 fnOS collector 注入 `LOCAL_OPENAI_BASE_URL`,避免运行链继续误依赖本机 `8317` +- 公网 collector 示例配置改为显式禁用 `local_model`,并把 `ASR` 桥接端口切到 `127.0.0.1:28088` +- 新增 Windows `ASR HTTP` 服务资产,兼容 StoryForge 当前 `/transcribe` 协议,便于把 ASR 迁到 Windows 主机 `192.168.31.18` +- Windows 端新增 `ASR` 启动脚本、云端桥接脚本与计划任务注册脚本,并放通 `8088` 入站,保证局域网和公网都可直连该 `ASR` 服务 diff --git a/deploy/STORYFORGE_PUBLIC_GATEWAY.md b/deploy/STORYFORGE_PUBLIC_GATEWAY.md index 1a3e28e..4bd768b 100644 --- a/deploy/STORYFORGE_PUBLIC_GATEWAY.md +++ b/deploy/STORYFORGE_PUBLIC_GATEWAY.md @@ -21,8 +21,8 @@ - 云服务器 `127.0.0.1:8081` -> 云服务器本地 `collector-service` - 云服务器 `127.0.0.1:19191` -> 云服务器本地 `StoryForge Web V4` 静态服务 - 云服务器 `127.0.0.1:15670` -> 本机 `n8n :5670` -- 云服务器 `127.0.0.1:18317` -> 本机模型网关 `:8317` -- 云服务器 `127.0.0.1:18088` -> 本机 `ASR :8088` +- 云服务器不再默认依赖本机模型网关 +- 云服务器 `127.0.0.1:28088` -> Windows `ASR :8088` - 云服务器 `127.0.0.1:15678` -> 本机 `huobao :5678` - 云服务器 `127.0.0.1:17860` -> 局域网 Windows `cutvideo :7860` - 云服务器 `127.0.0.1:19106` -> 局域网 NAS `live-recorder :19106` diff --git a/deploy/storyforge-collector.service.example b/deploy/storyforge-collector.service.example index a590006..5af84b3 100644 --- a/deploy/storyforge-collector.service.example +++ b/deploy/storyforge-collector.service.example @@ -12,8 +12,8 @@ Environment=JOBS_DIR=/home/ubuntu/storyforge/data/collector/jobs Environment=DOWNLOADS_DIR=/home/ubuntu/storyforge/data/collector/downloads Environment=MODELS_DIR=/home/ubuntu/storyforge/data/collector/models Environment=DEFAULT_EXTERNAL_BASE_URL=https://storyforge.hyzq.net -Environment=LOCAL_OPENAI_BASE_URL=http://127.0.0.1:18317/v1 -Environment=ASR_HTTP_BASE_URL=http://127.0.0.1:18088 +Environment=LOCAL_OPENAI_BASE_URL= +Environment=ASR_HTTP_BASE_URL=http://127.0.0.1:28088 Environment=N8N_BASE_URL=http://127.0.0.1:15670 Environment=ORCHESTRATOR_SHARED_SECRET=__set_a_strong_shared_secret__ Environment=BOOTSTRAP_SUPERADMIN_USERNAME=storyforge-admin diff --git a/deploy/storyforge-windows-asr-http/app.py b/deploy/storyforge-windows-asr-http/app.py new file mode 100644 index 0000000..5e80f61 --- /dev/null +++ b/deploy/storyforge-windows-asr-http/app.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import os +import tempfile +import time +from functools import lru_cache +from pathlib import Path + +from fastapi import FastAPI, File, UploadFile +from fastapi.responses import JSONResponse + +MODEL_NAME = os.getenv("WHISPER_MODEL", "base") +LANGUAGE = os.getenv("WHISPER_LANGUAGE", "zh") +DEVICE = os.getenv("WHISPER_DEVICE", "cpu") +COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8") +BEAM_SIZE = int(os.getenv("WHISPER_BEAM_SIZE", "5")) +VAD_FILTER = os.getenv("WHISPER_VAD_FILTER", "1").strip().lower() not in {"0", "false", "no"} +DOWNLOAD_ROOT = Path(os.getenv("WHISPER_DOWNLOAD_ROOT", str(Path(__file__).resolve().parent / "models-cache"))) + +app = FastAPI(title="storyforge-windows-asr", version="1.0.0") + + +@lru_cache(maxsize=1) +def get_model(): + from faster_whisper import WhisperModel + + DOWNLOAD_ROOT.mkdir(parents=True, exist_ok=True) + return WhisperModel( + MODEL_NAME, + device=DEVICE, + compute_type=COMPUTE_TYPE, + download_root=str(DOWNLOAD_ROOT), + ) + + +@app.get("/health") +def health() -> dict[str, object]: + return { + "status": "ok", + "service": "storyforge-windows-asr", + "model_name": MODEL_NAME, + "language": LANGUAGE, + "device": DEVICE, + "compute_type": COMPUTE_TYPE, + "download_root": str(DOWNLOAD_ROOT), + "model_loaded": get_model.cache_info().currsize > 0, + } + + +@app.get("/") +def root() -> dict[str, str]: + return {"service": "storyforge-windows-asr", "docs": "/docs"} + + +@app.post("/transcribe", response_model=None) +async def transcribe(wav: UploadFile = File(...)): + started = time.perf_counter() + suffix = Path(wav.filename or "segment.wav").suffix or ".wav" + with tempfile.NamedTemporaryFile(prefix="storyforge-asr-", suffix=suffix, delete=False) as handle: + temp_path = Path(handle.name) + handle.write(await wav.read()) + + try: + model = get_model() + segments, _info = model.transcribe( + str(temp_path), + language=LANGUAGE or None, + beam_size=max(1, BEAM_SIZE), + vad_filter=VAD_FILTER, + ) + text = "".join(segment.text for segment in segments).strip() + duration_ms = int((time.perf_counter() - started) * 1000) + return { + "text": text, + "success": bool(text), + "duration_ms": duration_ms, + "error_message": None if text else "empty transcription", + } + except Exception as exc: + return JSONResponse( + status_code=500, + content={ + "text": "", + "success": False, + "duration_ms": int((time.perf_counter() - started) * 1000), + "error_message": str(exc), + }, + ) + finally: + temp_path.unlink(missing_ok=True) diff --git a/deploy/storyforge-windows-asr-http/bridge-cloud.ps1 b/deploy/storyforge-windows-asr-http/bridge-cloud.ps1 new file mode 100644 index 0000000..853fa0d --- /dev/null +++ b/deploy/storyforge-windows-asr-http/bridge-cloud.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference = "Stop" + +$serverHost = if ($env:STORYFORGE_CLOUD_HOST) { $env:STORYFORGE_CLOUD_HOST } else { "111.231.132.51" } +$serverUser = if ($env:STORYFORGE_CLOUD_USER) { $env:STORYFORGE_CLOUD_USER } else { "ubuntu" } +$localPort = if ($env:STORYFORGE_ASR_LOCAL_PORT) { $env:STORYFORGE_ASR_LOCAL_PORT } else { "8088" } +$remotePort = if ($env:STORYFORGE_ASR_REMOTE_PORT) { $env:STORYFORGE_ASR_REMOTE_PORT } else { "28088" } +$identity = if ($env:STORYFORGE_CLOUD_IDENTITY) { $env:STORYFORGE_CLOUD_IDENTITY } else { (Join-Path $env:USERPROFILE ".ssh\storyforge_cloud_bridge_ed25519") } + +$sshArgs = @( + "-N", + "-i", $identity, + "-o", "StrictHostKeyChecking=no", + "-o", "ServerAliveInterval=30", + "-o", "ServerAliveCountMax=3", + "-R", "127.0.0.1:$remotePort`:127.0.0.1:$localPort", + "$serverUser@$serverHost" +) + +& ssh.exe @sshArgs diff --git a/deploy/storyforge-windows-asr-http/launch-asr.ps1 b/deploy/storyforge-windows-asr-http/launch-asr.ps1 new file mode 100644 index 0000000..83965ed --- /dev/null +++ b/deploy/storyforge-windows-asr-http/launch-asr.ps1 @@ -0,0 +1,14 @@ +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$runScript = Join-Path $scriptDir "run.ps1" + +$existing = Get-NetTCPConnection -State Listen -LocalPort 8088 -ErrorAction SilentlyContinue +if ($existing) { + exit 0 +} + +Start-Process -FilePath "powershell.exe" ` + -ArgumentList @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $runScript) ` + -WorkingDirectory $scriptDir ` + -WindowStyle Hidden diff --git a/deploy/storyforge-windows-asr-http/register-tasks.ps1 b/deploy/storyforge-windows-asr-http/register-tasks.ps1 new file mode 100644 index 0000000..17af01c --- /dev/null +++ b/deploy/storyforge-windows-asr-http/register-tasks.ps1 @@ -0,0 +1,22 @@ +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$runScript = Join-Path $scriptDir "run.ps1" +$launchAsrScript = Join-Path $scriptDir "launch-asr.ps1" +$bridgeScript = Join-Path $scriptDir "bridge-cloud.ps1" + +$tasks = @( + @{ + Name = "StoryForgeWindowsAsr" + Script = $launchAsrScript + }, + @{ + Name = "StoryForgeWindowsAsrCloudBridge" + Script = $bridgeScript + } +) + +foreach ($task in $tasks) { + schtasks /Create /F /SC ONLOGON /RL HIGHEST /TN $task.Name /TR "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$($task.Script)`"" + schtasks /Run /TN $task.Name +} diff --git a/deploy/storyforge-windows-asr-http/requirements.txt b/deploy/storyforge-windows-asr-http/requirements.txt new file mode 100644 index 0000000..9ea4c4c --- /dev/null +++ b/deploy/storyforge-windows-asr-http/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +python-multipart==0.0.20 +faster-whisper>=1.1,<2 diff --git a/deploy/storyforge-windows-asr-http/run.ps1 b/deploy/storyforge-windows-asr-http/run.ps1 new file mode 100644 index 0000000..81509f3 --- /dev/null +++ b/deploy/storyforge-windows-asr-http/run.ps1 @@ -0,0 +1,28 @@ +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$venvDir = Join-Path $scriptDir ".venv" +$python = "py -3.11" + +if (-not (Test-Path $venvDir)) { + Invoke-Expression "$python -m venv `"$venvDir`"" +} + +$venvPython = Join-Path $venvDir "Scripts\python.exe" +& $venvPython -m pip install --upgrade pip +& $venvPython -m pip install -r (Join-Path $scriptDir "requirements.txt") + +$env:WHISPER_MODEL = if ($env:WHISPER_MODEL) { $env:WHISPER_MODEL } else { "base" } +$env:WHISPER_LANGUAGE = if ($env:WHISPER_LANGUAGE) { $env:WHISPER_LANGUAGE } else { "zh" } +$env:WHISPER_DEVICE = if ($env:WHISPER_DEVICE) { $env:WHISPER_DEVICE } else { "cpu" } +$env:WHISPER_COMPUTE_TYPE = if ($env:WHISPER_COMPUTE_TYPE) { $env:WHISPER_COMPUTE_TYPE } else { "int8" } +$env:WHISPER_BEAM_SIZE = if ($env:WHISPER_BEAM_SIZE) { $env:WHISPER_BEAM_SIZE } else { "5" } +$env:WHISPER_VAD_FILTER = if ($env:WHISPER_VAD_FILTER) { $env:WHISPER_VAD_FILTER } else { "1" } +$env:WHISPER_DOWNLOAD_ROOT = if ($env:WHISPER_DOWNLOAD_ROOT) { $env:WHISPER_DOWNLOAD_ROOT } else { (Join-Path $scriptDir "models-cache") } + +Push-Location $scriptDir +try { + & $venvPython -m uvicorn app:app --host 0.0.0.0 --port 8088 +} finally { + Pop-Location +} diff --git a/scripts/deploy_fnos_storyforge_collector.sh b/scripts/deploy_fnos_storyforge_collector.sh index 095aff3..e7bb533 100755 --- a/scripts/deploy_fnos_storyforge_collector.sh +++ b/scripts/deploy_fnos_storyforge_collector.sh @@ -48,12 +48,12 @@ COLLECTOR_LOCAL_BASE_IMAGE="${STORYFORGE_COLLECTOR_LOCAL_BASE_IMAGE:-python:3.11 COLLECTOR_BASE_IMAGE="${STORYFORGE_COLLECTOR_BASE_IMAGE:-docker.m.daocloud.io/library/python:3.11-slim}" WEB_AUTOLOGIN_ACCOUNT_USERNAME="${WEB_AUTOLOGIN_ACCOUNT_USERNAME:-kris}" ORCHESTRATOR_SHARED_SECRET="${ORCHESTRATOR_SHARED_SECRET:-storyforge-local-secret}" -LOCAL_OPENAI_BASE_URL="${LOCAL_OPENAI_BASE_URL:-${HOST_LAN_IP:+http://$HOST_LAN_IP:8317/v1}}" +LOCAL_OPENAI_BASE_URL="${LOCAL_OPENAI_BASE_URL:-}" LOCAL_OPENAI_MODEL="${LOCAL_OPENAI_MODEL:-GLM-5}" LOCAL_OPENAI_API_KEY="${LOCAL_OPENAI_API_KEY:-}" -N8N_BASE_URL="${N8N_BASE_URL:-${HOST_LAN_IP:+http://$HOST_LAN_IP:5670}}" -ASR_HTTP_BASE_URL="${ASR_HTTP_BASE_URL:-${HOST_LAN_IP:+http://$HOST_LAN_IP:8088}}" -HUOBAO_BASE_URL="${HUOBAO_BASE_URL:-${HOST_LAN_IP:+http://$HOST_LAN_IP:5678}}" +N8N_BASE_URL="${N8N_BASE_URL:-http://$FNOS_HOST:5670}" +ASR_HTTP_BASE_URL="${ASR_HTTP_BASE_URL:-http://192.168.31.18:8088}" +HUOBAO_BASE_URL="${HUOBAO_BASE_URL:-http://$FNOS_HOST:5678}" CUTVIDEO_BASE_URL="${CUTVIDEO_BASE_URL:-http://$FNOS_HOST:19186}" LIVE_RECORDER_BASE_URL="${LIVE_RECORDER_BASE_URL:-http://192.168.31.188:19106}" CLOUD_DB_PATH="${CLOUD_DB_PATH:-/home/ubuntu/storyforge/data/collector/storyforge.db}"