diff --git a/.env.local.example b/.env.local.example
deleted file mode 100644
index 6604913..0000000
--- a/.env.local.example
+++ /dev/null
@@ -1,32 +0,0 @@
-# Supabase
-NEXT_PUBLIC_SUPABASE_URL=
-NEXT_PUBLIC_SUPABASE_ANON_KEY=
-SUPABASE_SERVICE_ROLE_KEY=
-
-# AI Model (optional — app works in mock mode without this)
-DEEPSEEK_API_KEY=
-DEEPSEEK_BASE_URL=https://api.deepseek.com
-
-# Optional: Production STT/TTS providers
-# STT_PROVIDER=mock|browser|whisper
-# TTS_PROVIDER=mock|xunfei|volcengine|tencent
-
-# Xunfei TTS (recommended first provider in China)
-XUNFEI_APP_ID=
-XUNFEI_API_KEY=
-XUNFEI_API_SECRET=
-# Required when TTS_PROVIDER=xunfei. Use a V3-compatible vcn from the Xunfei console.
-XUNFEI_TTS_VOICE=
-# Specific Xunfei coach voices are selected in Settings from the app voice catalog.
-
-# Volcengine TTS
-VOLCENGINE_TTS_APP_ID=
-VOLCENGINE_TTS_ACCESS_TOKEN=
-VOLCENGINE_TTS_CLUSTER=volcano_tts
-VOLCENGINE_TTS_VOICE=BV001_streaming
-
-# Tencent Cloud TTS
-TENCENT_SECRET_ID=
-TENCENT_SECRET_KEY=
-TENCENT_TTS_REGION=ap-guangzhou
-TENCENT_TTS_VOICE=101001
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 49c15f3..cca62fd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,5 +1,8 @@
name: CI
+permissions:
+ contents: read
+
on:
pull_request:
push:
diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml
new file mode 100644
index 0000000..9b426b4
--- /dev/null
+++ b/.github/workflows/deploy-tencent.yml
@@ -0,0 +1,72 @@
+name: Deploy Tencent
+
+permissions:
+ contents: read
+
+on:
+ push:
+ branches:
+ - main
+ - release
+ workflow_dispatch:
+
+concurrency:
+ group: deploy-tencent-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ name: Deploy Web/API
+ runs-on: [self-hosted, linux, x64, tencent, meteorvoice]
+ environment: tencent
+ steps:
+ - name: Resolve target
+ id: target
+ shell: bash
+ run: |
+ if [ "${GITHUB_REF_NAME}" = "release" ]; then
+ echo "app_dir=/srv/meteorvoice-release" >> "$GITHUB_OUTPUT"
+ echo "pm2_name=meteorvoice-release" >> "$GITHUB_OUTPUT"
+ echo "port=3100" >> "$GITHUB_OUTPUT"
+ else
+ echo "app_dir=/srv/meteorvoice" >> "$GITHUB_OUTPUT"
+ echo "pm2_name=meteorvoice" >> "$GITHUB_OUTPUT"
+ echo "port=3101" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Deploy
+ shell: bash
+ run: |
+ retry() {
+ local attempts="$1"
+ local delay="$2"
+ shift 2
+
+ local attempt=1
+ until "$@"; do
+ if [ "$attempt" -ge "$attempts" ]; then
+ return 1
+ fi
+
+ echo "Command failed. Retrying in ${delay}s (${attempt}/${attempts})..."
+ sleep "$delay"
+ attempt=$((attempt + 1))
+ done
+ }
+
+ cd '${{ steps.target.outputs.app_dir }}'
+ retry 5 15 timeout 180s git fetch origin "$GITHUB_REF_NAME"
+ git checkout "$GITHUB_REF_NAME"
+ git reset --hard "origin/$GITHUB_REF_NAME"
+ retry 3 15 npm ci
+ set -a
+ . /etc/meteorvoice/meteorvoice.env
+ set +a
+ retry 2 15 npm run build
+ unset RUNNER_TRACKING_ID
+ PORT='${{ steps.target.outputs.port }}' HOSTNAME=127.0.0.1 \
+ pm2 restart '${{ steps.target.outputs.pm2_name }}' --update-env || \
+ PORT='${{ steps.target.outputs.port }}' HOSTNAME=127.0.0.1 \
+ pm2 start npm --name '${{ steps.target.outputs.pm2_name }}' -- run start --workspace @meteorvoice/web -- --hostname 127.0.0.1 --port '${{ steps.target.outputs.port }}'
+ pm2 save
+ retry 12 5 curl -fsS 'http://127.0.0.1:${{ steps.target.outputs.port }}/' >/dev/null
diff --git a/AGENTS.md b/AGENTS.md
index 9ca14fa..64cc5f1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -46,6 +46,8 @@ All spec, plan, and implementation handoff files live in `docs/`:
## Environment
-- `.env.local` — local dev credentials (never committed)
-- `.env.local.example` — template
-- Vercel env vars: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
+- `apps/web/.env.local` — Web/API local development credentials (never committed)
+- `apps/web/.env.local.example` — Web/API template
+- `apps/mobile/.env` — mobile build-time public variables only, such as `EXPO_PUBLIC_SUPABASE_URL` and `EXPO_PUBLIC_SUPABASE_ANON_KEY`
+- `/etc/meteorvoice/meteorvoice.env` — Tencent/server runtime environment for the deployed Web/API process
+- Vercel env vars mirror the Web/API server environment: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
diff --git a/README.md b/README.md
index a1aef60..63bfe75 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
@@ -72,8 +72,12 @@ sequenceDiagram
- Accent adaptation across sessions (American, British, Indian, Australian, and more)
- Non-blocking correction panel — corrections appear without interrupting the conversation
- Bilingual UI (English and Chinese) outside the conversation area
+- Response language routing — AI replies and correction explanations follow the selected UI language
- Theme switching with CSS custom properties
- Login, session history, and preference sync through Supabase
+- Shared ASR provider layer with Xunfei bootstrap, native iOS PCM capture, and provider diagnostics
+- Unified runtime feedback for loading, blocking operations, network errors, and 401 sign-out
+- Authenticated API guard for high-cost AI, TTS, ASR, and session routes
- Mock AI/STT/TTS providers for local development without API keys
- Native mobile client (iOS/Android) via Expo React Native
@@ -85,16 +89,18 @@ flowchart TB
Mobile[MeteorVoice Mobile
Expo React Native]
subgraph Platform[Backend]
- API[Next.js API Routes
TTS / Chat / Session / Turns]
+ API[Next.js API Routes
TTS / ASR / Chat / Session / Turns]
Supabase[Supabase
Auth / DB / Preferences]
AI[AI Provider
DeepSeek via Vercel AI SDK]
TTS[TTS Providers
Xunfei / Volcengine / Tencent / Azure]
+ ASR[ASR Providers
Native / Xunfei / Azure-ready]
end
subgraph Shared[packages/]
SharedPkg[shared
types / i18n / provider interfaces]
SessionCore[session-core
turn lifecycle / workflow]
APIClient[api-client
typed API calls]
+ Runtime[shared runtime
feedback / operation groups]
end
Web --> API
@@ -103,17 +109,26 @@ flowchart TB
API --> Supabase
API --> AI
API --> TTS
+ API --> ASR
Web --> Shared
Mobile --> Shared
+ Web --> Runtime
+ Mobile --> Runtime
```
Responsibility boundaries:
- `apps/web`: Next.js full-stack app — UI, API routes, server-side TTS/AI orchestration.
-- `apps/mobile`: Expo React Native native client — voice session UI, native audio, background keep-alive.
-- `packages/shared`: cross-client types, i18n strings, provider interfaces, TTS capabilities.
+- `apps/mobile`: Expo React Native native client — voice session UI, native audio, native PCM capture, background keep-alive.
+- `packages/shared`: cross-client types, i18n strings, provider interfaces, ASR/TTS capabilities, app feedback state, and grouped async operation helpers.
- `packages/session-core`: platform-neutral turn lifecycle and workflow state machine.
-- `packages/api-client`: typed API calls shared by Web and Mobile.
+- `packages/api-client`: typed API calls shared by Web and Mobile, including request timeout and formatted error handling.
+
+Settings synchronization now separates full refresh and targeted updates:
+
+- Page entry, login, foreground resume, and manual refresh use grouped operations so multiple API requests share one loading state.
+- A single setting save uses the returned `/api/preferences` payload and applies only the affected settings domain.
+- AI response language is passed as `responseLocale`; ASR recognition language is configured separately.
## Project Structure
@@ -171,12 +186,15 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
DEEPSEEK_API_KEY=your-deepseek-api-key # optional — mock AI works without it
```
-TTS provider keys (all optional — mock TTS works without them):
+ASR/TTS provider keys (all optional — mock/native fallback works without them):
```text
+ASR_PROVIDER=native
+TTS_PROVIDER=mock
XUNFEI_APP_ID=
XUNFEI_API_KEY=
XUNFEI_API_SECRET=
+XUNFEI_ASR_PRODUCT=zh_iat
XUNFEI_TTS_VOICE= # default fallback vcn; coach voice is selectable in Settings
VOLCENGINE_ACCESS_KEY=
VOLCENGINE_SECRET_KEY=
@@ -202,7 +220,7 @@ npm run dev
Open `http://127.0.0.1:3001`
-The app runs fully in mock mode without any API keys. Real AI replies require `DEEPSEEK_API_KEY`. Real voice output requires at least one TTS provider key.
+The app runs fully in mock mode without any API keys. Real AI replies require `DEEPSEEK_API_KEY`. Real voice output requires at least one TTS provider key. Remote ASR requires provider credentials and `ASR_PROVIDER`; otherwise mobile can use native speech recognition.
## Mobile
@@ -233,7 +251,7 @@ Set the API base URL in `apps/mobile/app.json`:
```json
"extra": {
"apiBaseUrl": "https://meteorvoice.jcmeteor.com",
- "apiBaseUrlPreview": "https://meteorvoice-pre.jcmeteor.com"
+ "apiBaseUrlPreview": "https://mv-pre.jcmeteor.com"
}
```
@@ -253,6 +271,20 @@ Each user's selected provider is stored in Supabase. Provider credentials stay i
See `docs/tts-integration.md` for provider setup details.
+## ASR Providers
+
+MeteorVoice now exposes a shared ASR provider layer:
+
+| Provider | Key | Status |
+|----------|-----|--------|
+| Native mobile speech | `native` | Default fallback |
+| Xunfei `zh_iat` | `xunfei` | Signed WebSocket bootstrap and iOS PCM streaming path |
+| Azure Speech | `azure` | Contract-ready, pending runtime adapter |
+
+The ASR layer is split from AI response language routing: `ASR languageMode` controls recognition, while `responseLocale` controls how the coach replies.
+
+See `docs/asr-provider-layer.md` for contracts, rollout steps, diagnostics, and test guidance.
+
## Validation
```bash
@@ -280,6 +312,7 @@ flowchart LR
| **Dual-platform architecture** | `apps/web` + `apps/mobile` + `packages/*` monorepo | ✅ Delivered |
| **Immersive UI** | Voice waveform, desktop/mobile layouts, real-time subtitles | ✅ Delivered |
| **Productization** | History with expansion/pagination, cross-device preferences sync, CI parallel jobs, mobile audio hardening | ✅ Delivered |
-| **Semantic endpointing** | L1 local rules + L2 LLM-based end-of-turn detection + L3 safety net, replacing fixed silence timeout | 🚧 In implementation |
-| **Accent capabilities** | TTS provider voice catalog, multi-accent speakers (US/UK/Indian/Australian) | 📋 Planned |
+| **Semantic endpointing** | L1 local rules + L2 LLM-based end-of-turn detection + L3 safety net, replacing fixed silence timeout | ✅ Delivered |
+| **ASR provider layer** | Shared ASR contracts, Xunfei bootstrap, native PCM diagnostics, mobile session integration | ✅ Delivered |
+| **Accent capabilities** | TTS provider voice catalog, multi-accent speakers (US/UK/Indian/Australian) | 🚧 In implementation |
| **Distribution** | TestFlight beta, EAS production build | 📋 Planned |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 0539a32..0ca61ef 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -5,7 +5,7 @@
-
+
@@ -72,8 +72,12 @@ sequenceDiagram
- 会话级口音适配(美式、英式、印度、澳洲等)
- 非阻塞纠错面板——纠错建议不打断对话
- 对话区外支持中英双语 UI
+- AI 回复语言路由和 ASR 识别语言分离,避免中英文设置互相覆盖
- 基于 CSS 自定义属性的全局主题切换
- 通过 Supabase 管理登录、历史记录和偏好同步
+- 统一 ASR provider 层,支持讯飞启动会话、iOS 原生 PCM 采集和诊断
+- 统一运行时反馈能力,覆盖加载、阻断操作、网络错误和 401 强制登出
+- 高成本 AI、TTS、ASR、会话接口增加登录态保护
- 提供 mock AI/STT/TTS,无需 API Key 即可本地开发
- 基于 Expo React Native 的原生移动端(iOS/Android)
@@ -85,16 +89,18 @@ flowchart TB
Mobile[MeteorVoice Mobile
Expo React Native]
subgraph Platform[后端]
- API[Next.js API Routes
TTS / Chat / Session / Turns]
+ API[Next.js API Routes
TTS / ASR / Chat / Session / Turns]
Supabase[Supabase
Auth / DB / 偏好]
AI[AI Provider
DeepSeek via Vercel AI SDK]
TTS[TTS 服务商
讯飞 / 火山 / 腾讯 / Azure]
+ ASR[ASR 服务商
Native / 讯飞 / Azure-ready]
end
subgraph Shared[packages/]
SharedPkg[shared
类型 / i18n / provider 接口]
SessionCore[session-core
轮次生命周期 / 工作流]
APIClient[api-client
类型化 API 调用]
+ Runtime[shared runtime
反馈 / 操作组]
end
Web --> API
@@ -103,17 +109,26 @@ flowchart TB
API --> Supabase
API --> AI
API --> TTS
+ API --> ASR
Web --> Shared
Mobile --> Shared
+ Web --> Runtime
+ Mobile --> Runtime
```
职责边界:
- `apps/web`:Next.js 全栈应用——UI、API 路由、服务端 TTS/AI 编排。
-- `apps/mobile`:Expo React Native 原生客户端——语音会话 UI、原生音频、后台保活。
-- `packages/shared`:跨端类型、i18n 文案、provider 接口、TTS 能力表。
+- `apps/mobile`:Expo React Native 原生客户端——语音会话 UI、原生音频、原生 PCM 采集、后台保活。
+- `packages/shared`:跨端类型、i18n 文案、provider 接口、ASR/TTS 能力表、反馈状态和操作组。
- `packages/session-core`:平台无关的轮次生命周期和工作流状态机。
-- `packages/api-client`:Web 和 Mobile 共用的类型化 API 客户端。
+- `packages/api-client`:Web 和 Mobile 共用的类型化 API 客户端,包含请求超时和统一错误格式化。
+
+设置同步现在区分全量刷新和局部更新:
+
+- 进入页面、登录、前后台恢复和手动刷新使用 grouped operation,多个接口共享一个 loading 状态。
+- 单项设置保存成功后,以 `/api/preferences` 返回结果为准,只应用受影响的设置域。
+- AI 回复语言通过 `responseLocale` 传递;ASR 识别语言单独通过 `languageMode` 配置。
## 项目结构
@@ -171,12 +186,15 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
DEEPSEEK_API_KEY=your-deepseek-api-key # 可选,不填则使用 mock AI
```
-TTS 服务商密钥(均可选,不填则使用 mock TTS):
+ASR/TTS 服务商密钥(均可选,不填则使用 mock/native 兜底):
```text
+ASR_PROVIDER=native
+TTS_PROVIDER=mock
XUNFEI_APP_ID=
XUNFEI_API_KEY=
XUNFEI_API_SECRET=
+XUNFEI_ASR_PRODUCT=zh_iat
XUNFEI_TTS_VOICE= # 默认兜底 vcn;具体教练声音在设置页选择
VOLCENGINE_ACCESS_KEY=
VOLCENGINE_SECRET_KEY=
@@ -202,7 +220,7 @@ npm run dev
打开 `http://127.0.0.1:3001`
-不配置任何 API Key 也能以 mock 模式完整运行。真实 AI 回复需要 `DEEPSEEK_API_KEY`,真实语音输出需要至少一个 TTS 服务商密钥。
+不配置任何 API Key 也能以 mock 模式完整运行。真实 AI 回复需要 `DEEPSEEK_API_KEY`,真实语音输出需要至少一个 TTS 服务商密钥。远端 ASR 需要配置服务商密钥和 `ASR_PROVIDER`;否则移动端可以使用原生语音识别。
## 移动端
@@ -233,7 +251,7 @@ eas build --platform ios --profile preview
```json
"extra": {
"apiBaseUrl": "https://meteorvoice.jcmeteor.com",
- "apiBaseUrlPreview": "https://meteorvoice-pre.jcmeteor.com"
+ "apiBaseUrlPreview": "https://mv-pre.jcmeteor.com"
}
```
@@ -253,6 +271,20 @@ eas build --platform ios --profile preview
详见 `docs/tts-integration.md`。
+## ASR 服务商
+
+MeteorVoice 现在提供统一 ASR provider 层:
+
+| 服务商 | Key | 状态 |
+|--------|-----|------|
+| 原生移动端语音识别 | `native` | 默认兜底 |
+| 讯飞 `zh_iat` | `xunfei` | 已支持签名 WebSocket 启动和 iOS PCM streaming 路径 |
+| Azure Speech | `azure` | 合约已准备,运行时 adapter 待接入 |
+
+ASR 层和 AI 回复语言路由是分离的:`ASR languageMode` 只控制识别语言,`responseLocale` 控制教练回复语言。
+
+合约、落地路径、诊断和测试说明详见 `docs/asr-provider-layer.md`。
+
## 验证
```bash
@@ -280,6 +312,7 @@ flowchart LR
| **双端架构** | `apps/web` + `apps/mobile` + `packages/*` monorepo | ✅ 已交付 |
| **沉浸式 UI** | 语音波形、桌面/移动双布局、实时字幕 | ✅ 已交付 |
| **产品化** | 历史页详展/分页、Preferences 跨设备同步、CI 并行化、Mobile 音频硬化 | ✅ 已交付 |
-| **三合一层级判停** | L1 本地判断 + L2 LLM 语义判停 + L3 安全网,替代固定静默等待 | 🚧 实现中 |
-| **口音能力专项** | TTS provider voice catalog,多口音发音人(美式/英式/印度/澳式) | 📋 待排期 |
+| **三合一层级判停** | L1 本地判断 + L2 LLM 语义判停 + L3 安全网,替代固定静默等待 | ✅ 已交付 |
+| **ASR provider 层** | 统一 ASR 合约、讯飞启动会话、原生 PCM 诊断、移动端会话接入 | ✅ 已交付 |
+| **口音能力专项** | TTS provider voice catalog,多口音发音人(美式/英式/印度/澳式) | 🚧 实现中 |
| **分发** | TestFlight 内测、EAS 正式构建 | 📋 待排期 |
diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example
new file mode 100644
index 0000000..f22d777
--- /dev/null
+++ b/apps/mobile/.env.example
@@ -0,0 +1,2 @@
+EXPO_PUBLIC_SUPABASE_URL=
+EXPO_PUBLIC_SUPABASE_ANON_KEY=
diff --git a/apps/mobile/app.json b/apps/mobile/app.json
index 6250956..4c3f677 100644
--- a/apps/mobile/app.json
+++ b/apps/mobile/app.json
@@ -3,7 +3,7 @@
"name": "MeteorVoice",
"slug": "meteorvoice-mobile",
"owner": "junchenmeteors-team",
- "version": "1.2.0",
+ "version": "1.3.0",
"orientation": "portrait",
"scheme": "meteorvoice",
"userInterfaceStyle": "automatic",
@@ -11,19 +11,22 @@
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.tzcpa.meteorvoice",
- "buildNumber": "2026053107",
+ "buildNumber": "2026060601",
"deploymentTarget": "18.0",
"infoPlist": {
"NSMicrophoneUsageDescription": "MeteorVoice uses the microphone for voice practice sessions.",
"UIBackgroundModes": [
"audio"
],
- "NSSpeechRecognitionUsageDescription": "MeteorVoice uses speech recognition to turn your practice speech into text."
+ "NSSpeechRecognitionUsageDescription": "MeteorVoice uses speech recognition to turn your practice speech into text.",
+ "NSAppTransportSecurity": {
+ "NSAllowsArbitraryLoads": false
+ }
}
},
"android": {
"package": "com.jcmeteor.meteorvoice",
- "versionCode": 2026053107,
+ "versionCode": 2026060601,
"permissions": [
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS",
@@ -57,7 +60,7 @@
],
"extra": {
"apiBaseUrl": "https://meteorvoice.jcmeteor.com",
- "apiBaseUrlPreview": "https://meteorvoice-pre.jcmeteor.com",
+ "apiBaseUrlPreview": "https://mv-pre.jcmeteor.com",
"eas": {
"projectId": "9590b05e-34a0-4e1c-8c41-3841b1765c77"
}
diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png
index cd17172..b918023 100644
Binary files a/apps/mobile/assets/icon.png and b/apps/mobile/assets/icon.png differ
diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json
index e96d030..064e41d 100644
--- a/apps/mobile/eas.json
+++ b/apps/mobile/eas.json
@@ -7,7 +7,7 @@
"developmentClient": true,
"distribution": "internal",
"env": {
- "EXPO_PUBLIC_API_BASE_URL": "https://meteorvoice-pre.jcmeteor.com"
+ "EXPO_PUBLIC_API_BASE_URL": "https://mv-pre.jcmeteor.com"
},
"ios": {
"simulator": true
@@ -16,7 +16,7 @@
"preview": {
"distribution": "internal",
"env": {
- "EXPO_PUBLIC_API_BASE_URL": "https://meteorvoice-pre.jcmeteor.com"
+ "EXPO_PUBLIC_API_BASE_URL": "https://mv-pre.jcmeteor.com"
},
"ios": {
"simulator": false
diff --git a/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj b/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj
index b5c3261..3ae5a9a 100644
--- a/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj
+++ b/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj
@@ -338,7 +338,7 @@
CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2026053107;
+ CURRENT_PROJECT_VERSION = 2026060601;
DEVELOPMENT_TEAM = LGGMBJG4VB;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
@@ -351,7 +351,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.2.0;
+ MARKETING_VERSION = 1.3.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -376,7 +376,7 @@
CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 2026053107;
+ CURRENT_PROJECT_VERSION = 2026060601;
DEVELOPMENT_TEAM = LGGMBJG4VB;
INFOPLIST_FILE = MeteorVoice/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
@@ -384,7 +384,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 1.2.0;
+ MARKETING_VERSION = 1.3.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
diff --git a/apps/mobile/ios/MeteorVoice/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/apps/mobile/ios/MeteorVoice/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png
index b43044e..3c1e163 100644
Binary files a/apps/mobile/ios/MeteorVoice/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png and b/apps/mobile/ios/MeteorVoice/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ
diff --git a/apps/mobile/ios/MeteorVoice/Info.plist b/apps/mobile/ios/MeteorVoice/Info.plist
index 6cc1f69..fe9f4b3 100644
--- a/apps/mobile/ios/MeteorVoice/Info.plist
+++ b/apps/mobile/ios/MeteorVoice/Info.plist
@@ -38,13 +38,11 @@
12.0
LSRequiresIPhoneOS
- NSAppTransportSecurity
-
- NSAllowsArbitraryLoads
-
- NSAllowsLocalNetworking
-
-
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+
NSFaceIDUsageDescription
Allow $(PRODUCT_NAME) to access your Face ID biometric data.
NSMicrophoneUsageDescription
@@ -84,4 +82,4 @@
UIViewControllerBasedStatusBarAppearance
-
\ No newline at end of file
+
diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock
index 9b0f9a2..bafa939 100644
--- a/apps/mobile/ios/Podfile.lock
+++ b/apps/mobile/ios/Podfile.lock
@@ -1856,6 +1856,8 @@ PODS:
- ReactNativeDependencies (0.83.0)
- VoiceAudioSession (0.1.0):
- ExpoModulesCore
+ - VoicePcmCapture (0.1.0):
+ - ExpoModulesCore
- Yoga (0.0.0)
DEPENDENCIES:
@@ -1946,6 +1948,7 @@ DEPENDENCIES:
- ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`)
- ReactNativeDependencies (from `../../../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
- VoiceAudioSession (from `../modules/voice-audio-session/ios`)
+ - VoicePcmCapture (from `../modules/voice-pcm-capture/ios`)
- Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`)
EXTERNAL SOURCES:
@@ -2122,6 +2125,8 @@ EXTERNAL SOURCES:
:podspec: "../../../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
VoiceAudioSession:
:path: "../modules/voice-audio-session/ios"
+ VoicePcmCapture:
+ :path: "../modules/voice-pcm-capture/ios"
Yoga:
:path: "../../../node_modules/react-native/ReactCommon/yoga"
@@ -2212,6 +2217,7 @@ SPEC CHECKSUMS:
ReactCommon: e22f6e537e566de4c61121da2139a1980caff948
ReactNativeDependencies: d4bf16fb4f11558ffe141c42146791892f58666f
VoiceAudioSession: 480d1183a4f27f9f0253116e067343f1a5067af0
+ VoicePcmCapture: 341b0ed696cea24b337066c8ce56738c9a89de61
Yoga: 90687fcd1ebd927341caed917696d692813c0b24
PODFILE CHECKSUM: 09905f1f2c1d6ab15e8eda814d07fdc5f2141069
diff --git a/apps/mobile/modules/voice-pcm-capture/expo-module.config.json b/apps/mobile/modules/voice-pcm-capture/expo-module.config.json
new file mode 100644
index 0000000..73bac84
--- /dev/null
+++ b/apps/mobile/modules/voice-pcm-capture/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["ios"],
+ "ios": {
+ "modules": ["VoicePcmCaptureModule"]
+ }
+}
diff --git a/apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCapture.podspec b/apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCapture.podspec
new file mode 100644
index 0000000..936953c
--- /dev/null
+++ b/apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCapture.podspec
@@ -0,0 +1,26 @@
+require 'json'
+
+package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
+
+Pod::Spec.new do |s|
+ s.name = 'VoicePcmCapture'
+ s.version = package['version']
+ s.summary = package['description']
+ s.description = package['description']
+ s.license = package['license']
+ s.author = 'MeteorVoice'
+ s.homepage = 'https://meteorvoice.jcmeteor.com'
+ s.platforms = { :ios => '18.0' }
+ s.swift_version = '5.9'
+ s.source = { :path => '.' }
+ s.static_framework = true
+
+ s.dependency 'ExpoModulesCore'
+
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
+ }
+
+ s.source_files = '**/*.{swift}'
+end
diff --git a/apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCaptureModule.swift b/apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCaptureModule.swift
new file mode 100644
index 0000000..5974f11
--- /dev/null
+++ b/apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCaptureModule.swift
@@ -0,0 +1,353 @@
+import AVFoundation
+import ExpoModulesCore
+
+public class VoicePcmCaptureModule: Module {
+ private let captureQueue = DispatchQueue(label: "com.meteorvoice.voicePcmCapture")
+ private var engine: AVAudioEngine?
+ private var converter: AVAudioConverter?
+ private var targetFormat: AVAudioFormat?
+ private var pendingPcm = Data()
+ private var isCapturing = false
+ private var sequence = 0
+ private var totalBytes = 0
+ private var startedAtMs: Int64 = 0
+ private var frameSizeBytes = 1280
+ private var frameDurationMs = 40
+ private var sampleRate = 16000.0
+ private var routeChangeObserver: NSObjectProtocol?
+ private var interruptionObserver: NSObjectProtocol?
+ private var mediaServicesResetObserver: NSObjectProtocol?
+
+ public func definition() -> ModuleDefinition {
+ Name("VoicePcmCapture")
+
+ Events("onPcmCaptureFrame", "onPcmCaptureState")
+
+ AsyncFunction("start") { (options: [String: Any]) -> [String: Any] in
+ try self.startCapture(options: options)
+ }
+
+ AsyncFunction("stop") { (reason: String?) -> [String: Any] in
+ self.stopCapture(reason: reason ?? "manual")
+ }
+
+ AsyncFunction("getStatus") { () -> [String: Any] in
+ self.currentStatus()
+ }
+ }
+
+ private func startCapture(options: [String: Any]) throws -> [String: Any] {
+ if isCapturing {
+ return currentStatus()
+ }
+
+ sampleRate = options["sampleRate"] as? Double ?? 16000.0
+ frameDurationMs = options["frameDurationMs"] as? Int ?? 40
+ frameSizeBytes = Int(sampleRate * Double(frameDurationMs) / 1000.0) * 2
+
+ try configureAudioSession()
+
+ let nextEngine = AVAudioEngine()
+ let inputFormat = nextEngine.inputNode.outputFormat(forBus: 0)
+ guard let nextTargetFormat = AVAudioFormat(
+ commonFormat: .pcmFormatInt16,
+ sampleRate: sampleRate,
+ channels: 1,
+ interleaved: false
+ ) else {
+ throw NSError(
+ domain: "VoicePcmCapture",
+ code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to create target PCM format."]
+ )
+ }
+ guard let nextConverter = AVAudioConverter(from: inputFormat, to: nextTargetFormat) else {
+ throw NSError(
+ domain: "VoicePcmCapture",
+ code: 2,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to create PCM converter."]
+ )
+ }
+
+ pendingPcm.removeAll(keepingCapacity: false)
+ sequence = 0
+ totalBytes = 0
+ startedAtMs = nowMs()
+ targetFormat = nextTargetFormat
+ converter = nextConverter
+ isCapturing = true
+
+ do {
+ try installAndStartEngine(nextEngine, inputFormat: inputFormat)
+ } catch {
+ isCapturing = false
+ nextEngine.inputNode.removeTap(onBus: 0)
+ engine = nil
+ converter = nil
+ targetFormat = nil
+ throw error
+ }
+
+ installAudioSessionObservers()
+ sendState("started", message: nil)
+ return currentStatus()
+ }
+
+ private func stopCapture(reason: String) -> [String: Any] {
+ if !isCapturing {
+ return currentStatus(reason: reason)
+ }
+
+ isCapturing = false
+ engine?.inputNode.removeTap(onBus: 0)
+ engine?.stop()
+ engine = nil
+ converter = nil
+ targetFormat = nil
+ pendingPcm.removeAll(keepingCapacity: false)
+ removeAudioSessionObservers()
+
+ let status = currentStatus(reason: reason)
+ sendState("stopped", message: reason)
+ return status
+ }
+
+ private func configureAudioSession() throws {
+ let session = AVAudioSession.sharedInstance()
+ try session.setCategory(
+ .playAndRecord,
+ mode: .voiceChat,
+ options: [.allowBluetoothHFP, .defaultToSpeaker]
+ )
+ try session.setActive(true)
+ try session.overrideOutputAudioPort(.speaker)
+ }
+
+ private func installAndStartEngine(_ nextEngine: AVAudioEngine, inputFormat: AVAudioFormat) throws {
+ let inputNode = nextEngine.inputNode
+ let tapBufferSize = AVAudioFrameCount(max(1024, Int(inputFormat.sampleRate * 0.04)))
+ inputNode.installTap(onBus: 0, bufferSize: tapBufferSize, format: inputFormat) { [weak self] buffer, _ in
+ self?.captureQueue.async {
+ self?.handleInputBuffer(buffer)
+ }
+ }
+ nextEngine.prepare()
+ try nextEngine.start()
+ engine = nextEngine
+ }
+
+ private func restartCaptureEngine(reason: String) {
+ guard isCapturing else {
+ return
+ }
+
+ sendState("restarting", message: reason)
+ engine?.inputNode.removeTap(onBus: 0)
+ engine?.stop()
+ engine = nil
+ converter = nil
+ targetFormat = nil
+ pendingPcm.removeAll(keepingCapacity: false)
+
+ do {
+ try configureAudioSession()
+ let nextEngine = AVAudioEngine()
+ let inputFormat = nextEngine.inputNode.outputFormat(forBus: 0)
+ guard let nextTargetFormat = AVAudioFormat(
+ commonFormat: .pcmFormatInt16,
+ sampleRate: sampleRate,
+ channels: 1,
+ interleaved: false
+ ) else {
+ throw NSError(
+ domain: "VoicePcmCapture",
+ code: 3,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to recreate target PCM format."]
+ )
+ }
+ guard let nextConverter = AVAudioConverter(from: inputFormat, to: nextTargetFormat) else {
+ throw NSError(
+ domain: "VoicePcmCapture",
+ code: 4,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to recreate PCM converter."]
+ )
+ }
+
+ targetFormat = nextTargetFormat
+ converter = nextConverter
+ try installAndStartEngine(nextEngine, inputFormat: inputFormat)
+ sendState("restarted", message: reason)
+ } catch {
+ isCapturing = false
+ engine?.inputNode.removeTap(onBus: 0)
+ engine?.stop()
+ engine = nil
+ converter = nil
+ targetFormat = nil
+ pendingPcm.removeAll(keepingCapacity: false)
+ removeAudioSessionObservers()
+ sendState("error", message: error.localizedDescription)
+ }
+ }
+
+ private func installAudioSessionObservers() {
+ removeAudioSessionObservers()
+ let center = NotificationCenter.default
+ routeChangeObserver = center.addObserver(
+ forName: AVAudioSession.routeChangeNotification,
+ object: AVAudioSession.sharedInstance(),
+ queue: nil
+ ) { [weak self] notification in
+ let reasonValue = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
+ let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue ?? 0)
+ self?.captureQueue.async {
+ if reason == .categoryChange {
+ self?.sendState("route_change_ignored", message: "route_change:\(reasonValue ?? 0)")
+ return
+ }
+ self?.restartCaptureEngine(reason: "route_change:\(reasonValue ?? 0)")
+ }
+ }
+ interruptionObserver = center.addObserver(
+ forName: AVAudioSession.interruptionNotification,
+ object: AVAudioSession.sharedInstance(),
+ queue: nil
+ ) { [weak self] notification in
+ let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt
+ let type = AVAudioSession.InterruptionType(rawValue: typeValue ?? 0)
+ guard type == .ended else {
+ self?.sendState("interrupted", message: "audio_session_interruption")
+ return
+ }
+ self?.captureQueue.async {
+ self?.restartCaptureEngine(reason: "interruption_ended")
+ }
+ }
+ mediaServicesResetObserver = center.addObserver(
+ forName: AVAudioSession.mediaServicesWereResetNotification,
+ object: AVAudioSession.sharedInstance(),
+ queue: nil
+ ) { [weak self] _ in
+ self?.captureQueue.async {
+ self?.restartCaptureEngine(reason: "media_services_reset")
+ }
+ }
+ }
+
+ private func removeAudioSessionObservers() {
+ let center = NotificationCenter.default
+ if let routeChangeObserver {
+ center.removeObserver(routeChangeObserver)
+ self.routeChangeObserver = nil
+ }
+ if let interruptionObserver {
+ center.removeObserver(interruptionObserver)
+ self.interruptionObserver = nil
+ }
+ if let mediaServicesResetObserver {
+ center.removeObserver(mediaServicesResetObserver)
+ self.mediaServicesResetObserver = nil
+ }
+ }
+
+ private func handleInputBuffer(_ buffer: AVAudioPCMBuffer) {
+ guard isCapturing, let converter, let targetFormat else {
+ return
+ }
+
+ let ratio = targetFormat.sampleRate / buffer.format.sampleRate
+ let outputCapacity = AVAudioFrameCount(max(1, Double(buffer.frameLength) * ratio + 8))
+ guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputCapacity) else {
+ sendState("error", message: "Failed to allocate PCM output buffer.")
+ return
+ }
+
+ var didProvideInput = false
+ var convertError: NSError?
+ converter.convert(to: outputBuffer, error: &convertError) { _, outStatus in
+ if didProvideInput {
+ outStatus.pointee = .noDataNow
+ return nil
+ }
+ didProvideInput = true
+ outStatus.pointee = .haveData
+ return buffer
+ }
+
+ if let convertError {
+ sendState("error", message: convertError.localizedDescription)
+ return
+ }
+
+ guard let channelData = outputBuffer.int16ChannelData else {
+ sendState("error", message: "Converted PCM buffer has no int16 channel data.")
+ return
+ }
+
+ let byteCount = Int(outputBuffer.frameLength) * 2
+ if byteCount <= 0 {
+ return
+ }
+
+ pendingPcm.append(Data(bytes: channelData[0], count: byteCount))
+ emitCompleteFrames()
+ }
+
+ private func emitCompleteFrames() {
+ while pendingPcm.count >= frameSizeBytes {
+ let frame = pendingPcm.prefix(frameSizeBytes)
+ pendingPcm.removeFirst(frameSizeBytes)
+ sequence += 1
+ totalBytes += frameSizeBytes
+
+ let body: [String: Any] = [
+ "sequence": sequence,
+ "timestampMs": nowMs(),
+ "elapsedMs": max(0, nowMs() - startedAtMs),
+ "audioBase64": Data(frame).base64EncodedString(),
+ "byteCount": frameSizeBytes,
+ "sampleRate": Int(sampleRate),
+ "channels": 1,
+ "bitDepth": 16,
+ "durationMs": frameDurationMs,
+ ]
+
+ DispatchQueue.main.async {
+ self.sendEvent("onPcmCaptureFrame", body)
+ }
+ }
+ }
+
+ private func currentStatus(reason: String? = nil) -> [String: Any] {
+ var status: [String: Any] = [
+ "isCapturing": isCapturing,
+ "sampleRate": Int(sampleRate),
+ "channels": 1,
+ "bitDepth": 16,
+ "frameDurationMs": frameDurationMs,
+ "frameSizeBytes": frameSizeBytes,
+ "frameCount": sequence,
+ "totalBytes": totalBytes,
+ "elapsedMs": startedAtMs > 0 ? max(0, nowMs() - startedAtMs) : 0,
+ ]
+ if let reason {
+ status["reason"] = reason
+ }
+ return status
+ }
+
+ private func sendState(_ state: String, message: String?) {
+ var body = currentStatus()
+ body["state"] = state
+ if let message {
+ body["message"] = message
+ }
+ DispatchQueue.main.async {
+ self.sendEvent("onPcmCaptureState", body)
+ }
+ }
+
+ private func nowMs() -> Int64 {
+ Int64(Date().timeIntervalSince1970 * 1000)
+ }
+}
diff --git a/apps/mobile/modules/voice-pcm-capture/package.json b/apps/mobile/modules/voice-pcm-capture/package.json
new file mode 100644
index 0000000..6973922
--- /dev/null
+++ b/apps/mobile/modules/voice-pcm-capture/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "voice-pcm-capture",
+ "version": "0.1.0",
+ "description": "MeteorVoice iOS PCM microphone capture module.",
+ "main": "src/index.ts",
+ "license": "UNLICENSED",
+ "private": true
+}
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index edb5737..dd98185 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -1,6 +1,6 @@
{
"name": "@meteorvoice/mobile",
- "version": "1.2.0",
+ "version": "1.3.0",
"private": true,
"main": "index.ts",
"scripts": {
diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index 8593a57..73728c9 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -11,7 +11,9 @@ import {
} from 'react-native'
import {
createMeteorVoiceApiClient,
- MeteorVoiceApiError,
+ fetchWithTimeout,
+ formatApiRequestError,
+ type CreateASRSessionResponse,
type HistorySession,
type PreferencesResponse,
type SessionTurnDto,
@@ -46,8 +48,12 @@ import {
getScenarioDescription,
getScenarioLabel,
getTTSSpeedRouting,
+ runAppOperationGroup,
scenarios,
t,
+ appFeedback,
+ displayErrorFeedback,
+ type AppFeedbackState,
type ConversationMessage,
type ConversationResponse,
type Locale,
@@ -58,24 +64,47 @@ import * as SecureStore from 'expo-secure-store'
import { useMobileAuth } from './mobileAuth'
import { useNativeSessionAudio } from './nativeAudio'
import { useNativeSpeech } from './nativeSpeech'
-import { pullMobilePreferences, syncMobilePreferences, type XunfeiVoice } from './mobilePreferences'
+import { syncMobilePreferences, type XunfeiVoice } from './mobilePreferences'
import { getDefaultApiBaseUrl, getDisplayAppVersion } from './mobileConfig'
+import {
+ addPcmFrameListener,
+ addPcmStateListener,
+ isPcmCaptureAvailable,
+ startPcmCapture,
+ stopPcmCapture,
+ type PcmCaptureFrameEvent,
+} from './voicePcmCapture'
import { ThemeProvider, useTheme } from './ThemeProvider'
import { SessionScreen } from './screens/SessionScreen'
import { HomeScreen } from './screens/HomeScreen'
import { HistoryScreen } from './screens/HistoryScreen'
import { SettingsScreen } from './screens/SettingsScreen'
+import { AppFeedbackOverlay } from './components/AppFeedbackOverlay'
const defaultApiBaseUrl = getDefaultApiBaseUrl()
const appVersion = getDisplayAppVersion()
const apiBaseUrlStorageKey = 'api_base_url'
+const sessionSttProviderStorageKey = 'session_stt_provider'
type Tab = 'session' | 'home' | 'history' | 'settings'
+type ApiBaseUrlSource = 'default' | 'user'
+type SessionSttProvider = 'native' | 'xunfei'
type VoiceMetricEntry = {
ts: number
stage: string
data: Record
}
+type ASREvaluationRun = {
+ startedAt?: number
+ firstPartialMs?: number | null
+ finalMs?: number | null
+ chars?: number
+ source?: string
+ frameCount?: number
+ totalBytes?: number
+ error?: string
+}
+
const TAB_LABELS: Record = {
home: 'nav.home',
session: 'nav.practice',
@@ -114,6 +143,211 @@ function TabIcon({ tab, color }: { tab: Tab; color: string }) {
)
}
+function createASREvaluationReport(entries: VoiceMetricEntry[]) {
+ const nativeRuns: ASREvaluationRun[] = []
+ const remoteRuns: ASREvaluationRun[] = []
+ let currentNative: ASREvaluationRun | null = null
+ let currentRemote: ASREvaluationRun | null = null
+
+ for (const entry of entries) {
+ if (entry.stage === 'stt_start') {
+ currentNative = { startedAt: entry.ts }
+ nativeRuns.push(currentNative)
+ } else if (entry.stage === 'stt_first_partial' && currentNative) {
+ currentNative.firstPartialMs = readMetricNumber(entry.data.elapsedMs)
+ currentNative.chars = readMetricNumber(entry.data.chars) ?? currentNative.chars
+ } else if (entry.stage === 'stt_submit' && currentNative) {
+ currentNative.finalMs = readMetricNumber(entry.data.elapsedMs)
+ currentNative.chars = readMetricNumber(entry.data.chars) ?? currentNative.chars
+ currentNative.source = typeof entry.data.source === 'string' ? entry.data.source : undefined
+ } else if (entry.stage === 'stt_end' && currentNative && currentNative.finalMs == null) {
+ currentNative.finalMs = readMetricNumber(entry.data.elapsedMs)
+ }
+
+ if (entry.stage === 'asr_stream_start') {
+ currentRemote = { startedAt: entry.ts }
+ remoteRuns.push(currentRemote)
+ } else if (entry.stage === 'asr_first_partial' && currentRemote) {
+ currentRemote.firstPartialMs = readMetricNumber(entry.data.elapsedMs)
+ currentRemote.chars = readMetricNumber(entry.data.chars) ?? currentRemote.chars
+ } else if (entry.stage === 'asr_stream_done' && currentRemote) {
+ currentRemote.finalMs = readMetricNumber(entry.data.streamElapsedMs) ?? readMetricNumber(entry.data.elapsedMs)
+ currentRemote.chars = readMetricNumber(entry.data.transcriptChars) ?? currentRemote.chars
+ currentRemote.frameCount = readMetricNumber(entry.data.frameCount) ?? undefined
+ currentRemote.totalBytes = readMetricNumber(entry.data.totalBytes) ?? undefined
+ } else if (entry.stage === 'asr_stream_provider_error' && currentRemote) {
+ currentRemote.error = typeof entry.data.message === 'string' ? entry.data.message : 'Provider error'
+ } else if (entry.stage === 'asr_diagnostic_error' && currentRemote) {
+ currentRemote.error = typeof entry.data.message === 'string' ? entry.data.message : 'Diagnostic error'
+ }
+ }
+
+ const latestNative = nativeRuns.at(-1)
+ const latestRemote = remoteRuns.at(-1)
+ return [
+ 'ASR P4 evaluation report',
+ `Generated: ${new Date().toISOString()}`,
+ '',
+ `Native runs: ${nativeRuns.length}`,
+ formatASRRun('Latest native', latestNative),
+ '',
+ `Remote Xunfei runs: ${remoteRuns.length}`,
+ formatASRRun('Latest remote', latestRemote),
+ '',
+ 'Acceptance checks:',
+ '- Compare first partial latency between native and remote.',
+ '- Compare final latency between native and remote.',
+ '- Compare transcript chars and exported raw metrics against the spoken script.',
+ '- Do not switch production STT until remote accuracy and latency are better on device.',
+ ].join('\n')
+}
+
+function formatASRRun(label: string, run: ASREvaluationRun | undefined) {
+ if (!run) return `${label}: no run captured`
+ return [
+ `${label}:`,
+ ` startedAt: ${run.startedAt ? new Date(run.startedAt).toLocaleString() : 'unknown'}`,
+ ` firstPartialMs: ${formatMetricValue(run.firstPartialMs)}`,
+ ` finalMs: ${formatMetricValue(run.finalMs)}`,
+ ` chars: ${formatMetricValue(run.chars)}`,
+ run.source ? ` source: ${run.source}` : null,
+ run.frameCount != null ? ` frameCount: ${run.frameCount}` : null,
+ run.totalBytes != null ? ` totalBytes: ${run.totalBytes}` : null,
+ run.error ? ` error: ${run.error}` : null,
+ ].filter(Boolean).join('\n')
+}
+
+function readMetricNumber(value: unknown) {
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
+}
+
+function formatMetricValue(value: number | null | undefined) {
+ return value == null ? 'n/a' : String(value)
+}
+
+function withTimeout(promise: Promise, timeoutMs: number, message: string) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(message)), timeoutMs)
+ promise
+ .then(resolve, reject)
+ .finally(() => clearTimeout(timer))
+ })
+}
+
+function createXunfeiASRFrame(session: CreateASRSessionResponse, status: 0 | 1 | 2, audioBase64: string, sequence: number) {
+ const providerConfig = session.providerConfig
+ const header = {
+ app_id: providerConfig?.appId,
+ status,
+ }
+ const audio = {
+ encoding: providerConfig?.audioEncoding ?? 'raw',
+ sample_rate: providerConfig?.sampleRate ?? 16000,
+ channels: providerConfig?.channels ?? 1,
+ bit_depth: providerConfig?.bitDepth ?? 16,
+ seq: sequence,
+ status,
+ audio: audioBase64,
+ }
+ if (status !== 0) {
+ return {
+ header,
+ payload: { audio },
+ }
+ }
+
+ return {
+ header,
+ parameter: {
+ iat: {
+ domain: providerConfig?.domain ?? 'slm',
+ language: providerConfig?.language ?? 'zh_cn',
+ accent: providerConfig?.accent ?? 'mandarin',
+ eos: providerConfig?.eosMs ?? 900,
+ dwa: 'wpgs',
+ result: {
+ encoding: 'utf8',
+ compress: 'raw',
+ format: 'json',
+ },
+ },
+ },
+ payload: { audio },
+ }
+}
+
+function parseJsonObject(value: unknown): Record | null {
+ if (typeof value !== 'string') return null
+ try {
+ const parsed = JSON.parse(value)
+ return parsed && typeof parsed === 'object' ? parsed : null
+ } catch {
+ return null
+ }
+}
+
+function getObject(value: unknown): Record | null {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null
+}
+
+function extractXunfeiRecognitionResult(payload: Record | null) {
+ const payloadObject = getObject(payload?.payload)
+ const payloadResult = getObject(payloadObject?.result)
+ const encodedText = typeof payloadResult?.text === 'string' ? payloadResult.text : null
+ if (encodedText) {
+ const decoded = decodeBase64Utf8(encodedText)
+ const decodedPayload = parseJsonObject(decoded)
+ const decodedWords = extractXunfeiWords(decodedPayload?.ws)
+ if (decodedWords) {
+ const rg = Array.isArray(decodedPayload?.rg) &&
+ typeof decodedPayload.rg[0] === 'number' &&
+ typeof decodedPayload.rg[1] === 'number'
+ ? [decodedPayload.rg[0], decodedPayload.rg[1]] as [number, number]
+ : null
+ return {
+ text: decodedWords,
+ sn: typeof decodedPayload?.sn === 'number' ? decodedPayload.sn : null,
+ pgs: typeof decodedPayload?.pgs === 'string' ? decodedPayload.pgs : null,
+ rg,
+ }
+ }
+ }
+
+ const data = getObject(payload?.data)
+ const result = getObject(data?.result)
+ const fallbackWords = extractXunfeiWords(result?.ws)
+ return fallbackWords
+ ? { text: fallbackWords, sn: null, pgs: null, rg: null }
+ : null
+}
+
+function extractXunfeiWords(words: unknown) {
+ if (!Array.isArray(words)) return ''
+ return words.map(item => {
+ const word = getObject(item)
+ const candidates = word?.cw
+ if (!Array.isArray(candidates)) return ''
+ return candidates.map(candidate => {
+ const candidateObject = getObject(candidate)
+ return typeof candidateObject?.w === 'string' ? candidateObject.w : ''
+ }).join('')
+ }).join('')
+}
+
+function decodeBase64Utf8(value: string) {
+ try {
+ const decoder = globalThis.atob
+ if (!decoder) return ''
+ const binary = decoder(value)
+ const escaped = Array.from(binary)
+ .map(char => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
+ .join('')
+ return decodeURIComponent(escaped)
+ } catch {
+ return ''
+ }
+}
+
export default function App() {
return (
@@ -126,6 +360,7 @@ function AppInner() {
const { C, setTheme: setThemeLocal } = useTheme()
const [activeTab, setActiveTab] = useState('session')
const [apiBaseUrl, setApiBaseUrl] = useState(defaultApiBaseUrl)
+ const [apiBaseUrlSource, setApiBaseUrlSource] = useState('default')
const [messages, setMessages] = useState([])
const [correctionHistory, setCorrectionHistory] = useState([])
const [audioUrl, setAudioUrl] = useState(null)
@@ -138,7 +373,13 @@ function AppInner() {
useEffect(() => {
SecureStore.getItemAsync(apiBaseUrlStorageKey).then(value => {
const stored = value?.trim()
- if (stored) setApiBaseUrl(stored)
+ if (stored) {
+ setApiBaseUrl(stored)
+ setApiBaseUrlSource('user')
+ } else {
+ setApiBaseUrl(defaultApiBaseUrl)
+ setApiBaseUrlSource('default')
+ }
})
}, [])
const setLocale = useCallback((l: Locale) => {
@@ -155,8 +396,13 @@ function AppInner() {
const [selectedHistoryTurns, setSelectedHistoryTurns] = useState([])
const [settingsLoading, setSettingsLoading] = useState(false)
const [settingsMessage, setSettingsMessage] = useState(null)
+ const [authSubmitting, setAuthSubmitting] = useState(false)
+ const [scenarioSwitching, setScenarioSwitching] = useState(false)
+ const [activeFeedback, setActiveFeedback] = useState(() => appFeedback.getFeedback())
const [ttsProvider, setTtsProvider] = useState('mock')
const [availableProviders, setAvailableProviders] = useState(['mock'])
+ const [sessionSttProvider, setSessionSttProviderState] = useState('native')
+ const [availableSessionSttProviders, setAvailableSessionSttProviders] = useState(['native'])
const [ttsVoiceId, setTtsVoiceId] = useState(null)
const [voiceProfiles, setVoiceProfiles] = useState([])
const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState(null)
@@ -174,21 +420,48 @@ function AppInner() {
const audio = useNativeSessionAudio(audioUrl, ttsSpeedRouting.playbackRate)
const auth = useMobileAuth()
const getAuthHeaders = auth.getAuthHeaders
+ const signOut = auth.signOut
+ const snapshotRef = useRef(snapshot)
+ const messagesRef = useRef(messages)
+ const localeRef = useRef(locale)
+ const sessionSttProviderRef = useRef(sessionSttProvider)
+ const sessionSttProviderHydratedRef = useRef(false)
+ const sessionSttProvidersLoadedRef = useRef(false)
+ const startListeningWithProviderRef = useRef<(provider: SessionSttProvider, lang?: string) => Promise>(() => Promise.resolve(false))
const prefSyncTimerRef = useRef | null>(null)
const themeInitializedRef = useRef(false)
const listeningStartMsRef = useRef(0)
const speechStartListeningRef = useRef<(lang?: string) => Promise>(() => Promise.resolve(false))
const speechCancelListeningRef = useRef<() => void | Promise>(() => undefined)
+ const nativeSpeechStartListeningRef = useRef<(lang?: string) => Promise>(() => Promise.resolve(false))
+ const nativeSpeechCancelListeningRef = useRef<() => void | Promise>(() => undefined)
+ const xunfeiSessionSttRef = useRef<{
+ socket: WebSocket | null
+ frameSubscription: { remove: () => void } | null
+ stateSubscription: { remove: () => void } | null
+ finalizeTimer: ReturnType | null
+ hardTimer: ReturnType | null
+ noFrameTimer: ReturnType | null
+ settled: boolean
+ } | null>(null)
const endpointRequestRef = useRef(0)
+ const turnRequestRef = useRef(0)
const sessionActiveRef = useRef(false)
const canListenOnRouteRef = useRef(true)
const playbackActiveRef = useRef(false)
const audioPlayingRef = useRef(false)
+ const busyRef = useRef(false)
const playbackStartedRef = useRef(false)
const playbackEndedAtMsRef = useRef(null)
const pendingNativeTranscriptRef = useRef('')
const isCorrectionPlayingRef = useRef(false)
const resumeListeningTimerRef = useRef | null>(null)
+ const historyAutoLoadRef = useRef(false)
+ const settingsAutoLoadRef = useRef(false)
+ const settingsRequestRef = useRef(0)
+ const settingsLoadingRef = useRef(false)
+ const activeTabRef = useRef(activeTab)
+ const listeningTeardownRef = useRef | null>(null)
const scenario = scenarios.find(item => item.key === selectedScenarioKey) ?? scenarios[0]
const accent = accentProfiles.find(item => item.key === selectedAccentKey) ?? accentProfiles[0]
@@ -198,10 +471,60 @@ function AppInner() {
?? providerVoiceProfiles.find(profile => profile.status === 'active')
const sessionAccentName = selectedVoiceProfile?.accentLabel ?? getAccentLabel(accent, locale)
const sessionAccentRegion = selectedVoiceProfile?.accentRegion ?? getAccentRegion(accent, locale)
+ const tr = useCallback((key: string) => t[locale]?.[key] ?? t.en[key] ?? key, [locale])
+
+ useEffect(() => {
+ SecureStore.getItemAsync(sessionSttProviderStorageKey).then(value => {
+ if (value === 'xunfei' || value === 'native') {
+ sessionSttProviderRef.current = value
+ setSessionSttProviderState(value)
+ }
+ sessionSttProviderHydratedRef.current = true
+ })
+ }, [])
+
+ useEffect(() => {
+ snapshotRef.current = snapshot
+ }, [snapshot])
+
+ useEffect(() => {
+ messagesRef.current = messages
+ }, [messages])
+
+ useEffect(() => {
+ localeRef.current = locale
+ }, [locale])
+
+ useEffect(() => {
+ sessionSttProviderRef.current = sessionSttProvider
+ }, [sessionSttProvider])
+
+ useEffect(() => {
+ activeTabRef.current = activeTab
+ }, [activeTab])
+
+ useEffect(() => {
+ busyRef.current = busy
+ }, [busy])
+
+ const listeningStartupStatus = useCallback((provider = sessionSttProviderRef.current) => (
+ provider === 'xunfei'
+ ? 'session.status.preparing_listening'
+ : 'session.status.listening'
+ ), [])
+ const handleUnauthorized = useCallback(() => {
+ if (auth.state !== 'signed-in') return signOut(null)
+ return signOut(tr('settings.auth_expired'))
+ }, [auth.state, signOut, tr])
const api = useMemo(() => createMeteorVoiceApiClient({
baseUrl: apiBaseUrl.trim(),
headers: getAuthHeaders,
- }), [apiBaseUrl, getAuthHeaders])
+ onUnauthorized: handleUnauthorized,
+ }), [apiBaseUrl, getAuthHeaders, handleUnauthorized])
+ const setSettingsLoadingFlag = useCallback((loading: boolean) => {
+ settingsLoadingRef.current = loading
+ setSettingsLoading(loading)
+ }, [])
const applyThemeLocal = useCallback((k: Parameters[0]) => {
setThemeLocal(k)
}, [setThemeLocal])
@@ -212,8 +535,6 @@ function AppInner() {
void SecureStore.setItemAsync('theme_set_at', now)
void api.updatePreferences({ ui_theme: k }).catch(() => {})
}, [setThemeLocal, api])
- const tr = useCallback((key: string) => t[locale]?.[key] ?? t.en[key] ?? key, [locale])
-
const clearResumeListeningTimer = useCallback(() => {
if (!resumeListeningTimerRef.current) return
clearTimeout(resumeListeningTimerRef.current)
@@ -229,14 +550,79 @@ function AppInner() {
)
const entry = { ts: Date.now(), stage, data: sanitizedData }
console.info('[voice-metrics]', JSON.stringify(entry))
- setVoiceMetrics(previous => [...previous.slice(-79), entry])
+ setVoiceMetrics(previous => [...previous.slice(-239), entry])
}, [])
+ const logUserAction = useCallback((action: string, data: Record = {}) => {
+ logVoiceMetric('user_action', {
+ action,
+ activeTab: activeTabRef.current,
+ scenario: selectedScenarioKey,
+ sessionActive: sessionActiveRef.current,
+ canListenOnRoute: canListenOnRouteRef.current,
+ busy,
+ playbackActive: playbackActiveRef.current,
+ audioPlaying: audioPlayingRef.current,
+ sttProvider: sessionSttProviderRef.current,
+ pendingTeardown: Boolean(listeningTeardownRef.current),
+ ...data,
+ })
+ }, [busy, logVoiceMetric, selectedScenarioKey])
+
+ const runListeningTeardown = useCallback((reason: string, action: () => void | Promise) => {
+ const startedAt = Date.now()
+ logVoiceMetric('listening_teardown_start', {
+ reason,
+ provider: sessionSttProviderRef.current,
+ activeTab: activeTabRef.current,
+ sessionActive: sessionActiveRef.current,
+ canListenOnRoute: canListenOnRouteRef.current,
+ })
+ const task = Promise.resolve()
+ .then(action)
+ .catch(error => {
+ logVoiceMetric('listening_teardown_error', {
+ reason,
+ message: error instanceof Error ? error.message : 'Listening teardown failed',
+ })
+ })
+ .finally(() => {
+ if (listeningTeardownRef.current === task) {
+ listeningTeardownRef.current = null
+ }
+ logVoiceMetric('listening_teardown_done', {
+ reason,
+ elapsedMs: Date.now() - startedAt,
+ })
+ })
+ listeningTeardownRef.current = task
+ return task
+ }, [logVoiceMetric])
+
+ const waitForListeningTeardown = useCallback(async (context: string) => {
+ const pending = listeningTeardownRef.current
+ if (!pending) return
+ const startedAt = Date.now()
+ logVoiceMetric('listening_teardown_wait', { context })
+ await pending
+ logVoiceMetric('listening_teardown_wait_done', {
+ context,
+ elapsedMs: Date.now() - startedAt,
+ })
+ }, [logVoiceMetric])
+
+ const cancelListeningForReason = useCallback((reason: string) => (
+ runListeningTeardown(reason, async () => {
+ await speechCancelListeningRef.current()
+ })
+ ), [runListeningTeardown])
+
const voiceMetricsText = useMemo(() => {
return voiceMetrics
.map(entry => `${new Date(entry.ts).toLocaleTimeString()} ${entry.stage} ${JSON.stringify(entry.data)}`)
.join('\n')
}, [voiceMetrics])
+ const asrEvaluationText = useMemo(() => createASREvaluationReport(voiceMetrics), [voiceMetrics])
const scheduleResumeListening = useCallback((delayMs = DEFAULT_PLAYBACK_COOLDOWN_MS, updateStatus = true) => {
clearResumeListeningTimer()
@@ -250,10 +636,10 @@ function AppInner() {
return
}
listeningStartMsRef.current = Date.now()
- if (updateStatus) setStatus('session.status.listening')
+ if (updateStatus) setStatus(listeningStartupStatus())
void speechStartListeningRef.current('en-US')
}, delayMs)
- }, [clearResumeListeningTimer, logVoiceMetric])
+ }, [clearResumeListeningTimer, listeningStartupStatus, logVoiceMetric])
useEffect(() => clearResumeListeningTimer, [clearResumeListeningTimer])
@@ -261,14 +647,94 @@ function AppInner() {
setApiBaseUrl(value)
const normalized = value.trim()
if (!normalized || normalized === defaultApiBaseUrl) {
+ setApiBaseUrlSource('default')
void SecureStore.deleteItemAsync(apiBaseUrlStorageKey)
return
}
+ setApiBaseUrlSource('user')
void SecureStore.setItemAsync(apiBaseUrlStorageKey, normalized)
}, [])
- function startSession() {
- logVoiceMetric('session_start', { scenario: scenario.key, accent: accent.key, provider: ttsProvider })
+ const resetApiBaseUrl = useCallback(() => {
+ setApiBaseUrl(defaultApiBaseUrl)
+ setApiBaseUrlSource('default')
+ void SecureStore.deleteItemAsync(apiBaseUrlStorageKey)
+ }, [])
+
+ const setSessionSttProvider = useCallback((provider: SessionSttProvider) => {
+ sessionSttProviderRef.current = provider
+ setSessionSttProviderState(provider)
+ void SecureStore.setItemAsync(sessionSttProviderStorageKey, provider)
+ logVoiceMetric('stt_provider_selected', { provider })
+ }, [logVoiceMetric, setSessionSttProviderState])
+
+ async function ensureSessionSttProviderForStart() {
+ let provider = sessionSttProviderRef.current
+
+ if (!sessionSttProviderHydratedRef.current) {
+ const stored = await SecureStore.getItemAsync(sessionSttProviderStorageKey)
+ if (stored === 'xunfei' || stored === 'native') {
+ provider = stored
+ sessionSttProviderRef.current = stored
+ setSessionSttProviderState(stored)
+ }
+ sessionSttProviderHydratedRef.current = true
+ }
+
+ if (auth.state === 'signed-in' && !sessionSttProvidersLoadedRef.current) {
+ try {
+ const result = await api.listASRProviders()
+ const providers: SessionSttProvider[] = ['native']
+ if (result.providers.some(item => item.key === 'xunfei' && item.enabled)) {
+ providers.push('xunfei')
+ }
+ setAvailableSessionSttProviders(providers)
+ sessionSttProvidersLoadedRef.current = true
+ if (!providers.includes(provider)) {
+ provider = 'native'
+ sessionSttProviderRef.current = 'native'
+ setSessionSttProviderState('native')
+ void SecureStore.setItemAsync(sessionSttProviderStorageKey, 'native')
+ }
+ } catch (error) {
+ const requestError = formatApiRequestError(error, {
+ context: 'mobile_asr_providers_load',
+ presentation: 'silent',
+ })
+ logVoiceMetric('mobile_silent_request_error', requestError.logData)
+ }
+ }
+
+ return provider
+ }
+
+ async function startSession() {
+ logUserAction('session_start_tap', { scenario: scenario.key })
+ if (scenarioSwitching) {
+ logVoiceMetric('session_start_blocked', { reason: 'scenario_switching' })
+ return
+ }
+ if (auth.state !== 'signed-in') {
+ setActiveTab('settings')
+ setStatus('login.signin')
+ return
+ }
+
+ setStatus('session.status.preparing_listening')
+ logVoiceMetric('session_start_requested', {
+ scenario: scenario.key,
+ activeTab: activeTabRef.current,
+ pendingTeardown: Boolean(listeningTeardownRef.current),
+ })
+ await waitForListeningTeardown('session_start')
+ await cancelListeningForReason('session_start_reset')
+ const listeningProvider = await ensureSessionSttProviderForStart()
+ logVoiceMetric('session_start', {
+ scenario: scenario.key,
+ accent: accent.key,
+ provider: ttsProvider,
+ sttProvider: listeningProvider,
+ })
endpointRequestRef.current += 1
clearResumeListeningTimer()
playbackActiveRef.current = false
@@ -280,6 +746,8 @@ function AppInner() {
canListenOnRouteRef.current = true
const nextSessionId = apiSessionId ?? `mobile-${Date.now()}`
const nextSnapshot = startListeningSession(nextSessionId)
+ snapshotRef.current = nextSnapshot
+ messagesRef.current = []
setSnapshot(nextSnapshot)
setMessages([])
setCorrectionHistory([])
@@ -288,8 +756,13 @@ function AppInner() {
setPlaybackQueue(createPlaybackQueueSnapshot())
setSummary(null)
setIsSessionActive(true)
- setStatus('session.status.listening')
- void speechStartListeningRef.current('en-US')
+ setStatus(listeningStartupStatus(listeningProvider))
+ logVoiceMetric('session_listening_start_requested', {
+ sttProvider: listeningProvider,
+ sessionId: nextSessionId,
+ canListenOnRoute: canListenOnRouteRef.current,
+ })
+ void startListeningWithProviderRef.current(listeningProvider, 'en-US')
}
const synthesizeCoachSpeech = useCallback(async (text: string) => {
@@ -307,9 +780,9 @@ function AppInner() {
if (audio.isPlaying && audioUrl && playbackActiveRef.current && !playbackStartedRef.current) {
playbackStartedRef.current = true
logVoiceMetric('playback_started', { audioUrl })
- void speechCancelListeningRef.current()
+ void cancelListeningForReason('playback_started')
}
- }, [audio.isPlaying, audioUrl, logVoiceMetric])
+ }, [audio.isPlaying, audioUrl, cancelListeningForReason, logVoiceMetric])
useEffect(() => {
if (!audioUrl || !audio.didJustFinish || audio.isPlaying) return
@@ -352,7 +825,7 @@ function AppInner() {
playbackStartedRef.current = false
playbackEndedAtMsRef.current = null
clearResumeListeningTimer()
- void speechCancelListeningRef.current()
+ void cancelListeningForReason('play_next_audio')
setStatus('session.status.playing_reply')
setAudioUrl(nextQueue.currentAudioUrl)
return
@@ -373,11 +846,16 @@ function AppInner() {
cancelled = true
clearTimeout(timeout)
}
- }, [audio.didJustFinish, audio.isPlaying, audioUrl, playbackQueue, clearResumeListeningTimer, logVoiceMetric, scheduleResumeListening])
+ }, [
+ audio.didJustFinish, audio.isPlaying, audioUrl, playbackQueue, cancelListeningForReason,
+ clearResumeListeningTimer, logVoiceMetric, scheduleResumeListening,
+ ])
const submitTurn = useCallback(async (sourceTranscript: string) => {
const submitStartedAt = Date.now()
const transcript = sourceTranscript.trim()
+ const currentSnapshot = snapshotRef.current
+ const currentMessages = messagesRef.current
if (
busy ||
audio.isRecording ||
@@ -385,14 +863,16 @@ function AppInner() {
!canAcceptUserTranscript({
activeSession: isSessionActive,
canListenOnRoute: canListenOnRouteRef.current,
- workflowState: snapshot.state,
+ workflowState: currentSnapshot.state,
transcript,
})
) return
- const acceptedTurn = acceptTranscriptTurn({ snapshot, transcript, messages })
+ const acceptedTurn = acceptTranscriptTurn({ snapshot: currentSnapshot, transcript, messages: currentMessages })
const nextMessages = acceptedTurn.messages
let nextSnapshot = acceptedTurn.snapshot
+ snapshotRef.current = nextSnapshot
+ messagesRef.current = nextMessages
setSnapshot(nextSnapshot)
setMessages(nextMessages)
setAudioUrl(null)
@@ -401,22 +881,29 @@ function AppInner() {
playbackEndedAtMsRef.current = null
pendingNativeTranscriptRef.current = ''
clearResumeListeningTimer()
- await speechCancelListeningRef.current()
+ await cancelListeningForReason('submit_turn')
setBusy(true)
+ const turnRequestId = ++turnRequestRef.current
try {
setStatus('session.status.requesting_reply')
nextSnapshot = requestCoachReply(nextSnapshot)
+ snapshotRef.current = nextSnapshot
setSnapshot(nextSnapshot)
- const coachReply = await api.generateCoachReply({
+ const coachReply = await withTimeout(api.generateCoachReply({
messages: nextMessages,
context: {
scenario: { name: scenario.name, description: scenario.description },
accentProfile: { name: accent.name, region: accent.region },
sessionId: nextSnapshot.sessionId,
turnNumber: nextMessages.filter(message => message.role === 'user').length,
+ responseLocale: localeRef.current,
},
- })
+ }), 20_000, 'Coach reply request timed out.')
+ if (turnRequestRef.current !== turnRequestId || !sessionActiveRef.current) {
+ logVoiceMetric('coach_reply_ignored', { reason: 'session_inactive' })
+ return
+ }
logVoiceMetric('coach_reply_ready', {
elapsedMs: Date.now() - submitStartedAt,
chars: coachReply.text.length,
@@ -429,15 +916,24 @@ function AppInner() {
corrections: coachReply.corrections,
})
nextSnapshot = coachTurn.snapshot
+ snapshotRef.current = nextSnapshot
+ messagesRef.current = coachTurn.messages
setMessages(coachTurn.messages)
setSnapshot(nextSnapshot)
setStatus('session.status.requesting_voice')
if (!coachReply.text.trim()) {
setStatus('session.status.reply_without_text')
+ if (sessionActiveRef.current && canListenOnRouteRef.current) {
+ scheduleResumeListening(500)
+ }
+ return
+ }
+ const voice = await withTimeout(synthesizeCoachSpeech(coachReply.text), 20_000, 'Coach voice request timed out.')
+ if (turnRequestRef.current !== turnRequestId || !sessionActiveRef.current) {
+ logVoiceMetric('tts_ignored', { reason: 'session_inactive' })
return
}
- const voice = await synthesizeCoachSpeech(coachReply.text)
logVoiceMetric('tts_ready', {
elapsedMs: Date.now() - submitStartedAt,
hasAudio: Boolean(voice.audioUrl),
@@ -448,7 +944,7 @@ function AppInner() {
playbackStartedRef.current = false
playbackEndedAtMsRef.current = null
clearResumeListeningTimer()
- await speechCancelListeningRef.current()
+ await cancelListeningForReason('playback_enqueue')
setStatus('session.status.playing_reply')
setPlaybackQueue(startPlaybackQueue(voice.audioUrl))
setAudioUrl(voice.audioUrl)
@@ -456,11 +952,15 @@ function AppInner() {
} else {
playbackActiveRef.current = false
setStatus('session.status.reply_without_audio')
+ if (sessionActiveRef.current && canListenOnRouteRef.current) {
+ scheduleResumeListening(500)
+ }
}
const completedTurn = completeCoachPlayback({
snapshot: nextSnapshot,
corrections: coachReply.corrections,
})
+ snapshotRef.current = completedTurn.snapshot
setSnapshot(completedTurn.snapshot)
} catch (error) {
const recovery = recoverSessionError({
@@ -469,19 +969,25 @@ function AppInner() {
activeSession: isSessionActive,
canListenOnRoute: canListenOnRouteRef.current,
})
+ snapshotRef.current = recovery.snapshot
setSnapshot(recovery.snapshot)
- const message = error instanceof MeteorVoiceApiError
- ? `${error.message} (${error.status})`
- : error instanceof Error
- ? error.message
- : 'Request failed'
- setStatus(message)
+ const requestError = formatApiRequestError(error, {
+ context: 'mobile_session_submit',
+ presentation: 'banner',
+ })
+ logVoiceMetric('mobile_session_request_error', requestError.logData)
+ displayErrorFeedback(requestError, 'mobile_session_submit')
+ setStatus(requestError.displayMessage)
+ if (sessionActiveRef.current && canListenOnRouteRef.current) {
+ scheduleResumeListening(900)
+ }
} finally {
- setBusy(false)
+ if (turnRequestRef.current === turnRequestId) setBusy(false)
}
}, [
- accent.name, accent.region, api, audio.isRecording, busy, clearResumeListeningTimer, isSessionActive,
- logVoiceMetric, messages, scenario.description, scenario.name, snapshot, synthesizeCoachSpeech,
+ accent.name, accent.region, api, audio.isRecording, busy, cancelListeningForReason,
+ clearResumeListeningTimer, isSessionActive, logVoiceMetric, scenario.description, scenario.name,
+ scheduleResumeListening, synthesizeCoachSpeech,
])
const handleNativeFinalTranscript = useCallback(async (finalTranscript: string) => {
@@ -493,16 +999,18 @@ function AppInner() {
.filter(Boolean)
.join(' ')
- if (!isSessionActive) {
+ if (!sessionActiveRef.current) {
logVoiceMetric('transcript_ignored_inactive', { chars: transcript.length })
setStatus('session.status.speech_captured')
return
}
+ const currentSnapshot = snapshotRef.current
+ const currentMessages = messagesRef.current
const transcriptGate = gateUserTranscript({
activeSession: sessionActiveRef.current,
canListenOnRoute: canListenOnRouteRef.current,
- workflowState: snapshot.state,
+ workflowState: currentSnapshot.state,
transcript: endpointTranscript,
playbackActive: playbackActiveRef.current,
audioPlaying: audio.isPlaying,
@@ -516,7 +1024,7 @@ function AppInner() {
chars: endpointTranscript.length,
})
if (transcriptGate.reason === 'playback_active') {
- void speechCancelListeningRef.current()
+ void cancelListeningForReason('transcript_gate_playback_active')
}
pendingNativeTranscriptRef.current = ''
return
@@ -524,7 +1032,7 @@ function AppInner() {
const echoGuard = shouldIgnoreLikelyPlaybackEcho({
transcript: endpointTranscript,
- lastAssistantResponse: snapshot.lastResponse,
+ lastAssistantResponse: currentSnapshot.lastResponse,
playbackEndedAtMs: playbackEndedAtMsRef.current,
nowMs: Date.now(),
})
@@ -534,7 +1042,7 @@ function AppInner() {
chars: endpointTranscript.length,
})
pendingNativeTranscriptRef.current = ''
- setStatus('session.status.listening')
+ setStatus(listeningStartupStatus())
if (!playbackActiveRef.current && !audioPlayingRef.current) {
void speechStartListeningRef.current('en-US')
}
@@ -547,14 +1055,16 @@ function AppInner() {
const endpointResult = await judgeEndpoint({
transcript: endpointTranscript,
listeningDurationMs: Date.now() - listeningStartMsRef.current,
- messages,
+ messages: currentMessages,
scenario: scenario.key,
semanticCheck: auth.state === 'signed-in' ? async (t, ctx) => {
- const res = await fetch(`${baseUrl}/api/semantic-endpoint`, {
+ const authHeaders = await getAuthHeaders()
+ const res = await fetchWithTimeout(fetch, `${baseUrl}/api/semantic-endpoint`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
+ headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-mobile', ...authHeaders },
body: JSON.stringify({ transcript: t, messages: ctx.messages, scenario: ctx.scenario }),
})
+ if (res.status === 401) await handleUnauthorized()
if (!res.ok) throw new Error('Semantic check failed')
const data = await res.json() as { judgment: 'done' | 'thinking' }
return data.judgment
@@ -569,7 +1079,7 @@ function AppInner() {
if (endpointResult.judgment === 'continue') {
pendingNativeTranscriptRef.current = endpointTranscript
- setStatus('session.status.listening')
+ setStatus(listeningStartupStatus())
if (!playbackActiveRef.current && !audioPlayingRef.current) {
void speechStartListeningRef.current('en-US')
}
@@ -579,14 +1089,21 @@ function AppInner() {
pendingNativeTranscriptRef.current = ''
logVoiceMetric('submit_turn_start', { chars: endpointTranscript.length })
void submitTurn(endpointTranscript)
- }, [apiBaseUrl, audio.isPlaying, auth.state, getAuthHeaders, isSessionActive, logVoiceMetric, messages, scenario.key, snapshot.lastResponse, snapshot.state, submitTurn])
+ }, [
+ apiBaseUrl, audio.isPlaying, auth.state, cancelListeningForReason, getAuthHeaders, handleUnauthorized,
+ listeningStartupStatus, logVoiceMetric, scenario.key, submitTurn,
+ ])
const handleListeningEndedWithoutTranscript = useCallback(() => {
if (!sessionActiveRef.current || !canListenOnRouteRef.current || busy || playbackActiveRef.current || audioPlayingRef.current) {
logVoiceMetric('stt_end_restart_skipped', {
+ sessionActive: sessionActiveRef.current,
+ canListenOnRoute: canListenOnRouteRef.current,
+ activeTab: activeTabRef.current,
busy,
playbackActive: playbackActiveRef.current,
audioPlaying: audioPlayingRef.current,
+ provider: sessionSttProviderRef.current,
})
return
}
@@ -594,6 +1111,359 @@ function AppInner() {
scheduleResumeListening(250, false)
}, [busy, logVoiceMetric, scheduleResumeListening])
+ const cancelXunfeiSessionListening = useCallback(async (reason = 'cancel') => {
+ const current = xunfeiSessionSttRef.current
+ if (!current || current.settled) return
+ current.settled = true
+ if (current.finalizeTimer) clearTimeout(current.finalizeTimer)
+ if (current.hardTimer) clearTimeout(current.hardTimer)
+ if (current.noFrameTimer) clearTimeout(current.noFrameTimer)
+ current.frameSubscription?.remove()
+ current.stateSubscription?.remove()
+ if (current.socket && current.socket.readyState === WebSocket.OPEN) {
+ current.socket.close()
+ }
+ await stopPcmCapture(`session_${reason}`).catch(() => undefined)
+ xunfeiSessionSttRef.current = null
+ logVoiceMetric('stt_end', { provider: 'xunfei', cancelled: true, reason })
+ }, [logVoiceMetric])
+
+ const startXunfeiSessionListening = useCallback(async () => {
+ const canUseXunfeiRoute = (context: string) => {
+ const allowed = sessionActiveRef.current &&
+ canListenOnRouteRef.current &&
+ !busyRef.current &&
+ !playbackActiveRef.current &&
+ !audioPlayingRef.current &&
+ activeTabRef.current === 'session'
+ if (!allowed) {
+ logVoiceMetric('stt_start_aborted', {
+ provider: 'xunfei',
+ context,
+ sessionActive: sessionActiveRef.current,
+ canListenOnRoute: canListenOnRouteRef.current,
+ activeTab: activeTabRef.current,
+ busy: busyRef.current,
+ playbackActive: playbackActiveRef.current,
+ audioPlaying: audioPlayingRef.current,
+ })
+ }
+ return allowed
+ }
+
+ if (!canUseXunfeiRoute('entry')) return false
+ if (xunfeiSessionSttRef.current && !xunfeiSessionSttRef.current.settled) return true
+ if (!availableSessionSttProviders.includes('xunfei') || !isPcmCaptureAvailable()) {
+ logVoiceMetric('stt_provider_fallback', {
+ requested: 'xunfei',
+ reason: !isPcmCaptureAvailable() ? 'pcm_unavailable' : 'provider_unavailable',
+ })
+ return nativeSpeechStartListeningRef.current('en-US')
+ }
+ if (auth.state !== 'signed-in') return nativeSpeechStartListeningRef.current('en-US')
+
+ const streamStartedAt = Date.now()
+ let socket: WebSocket | null = null
+ let frameSubscription: { remove: () => void } | null = null
+ let stateSubscription: { remove: () => void } | null = null
+ let finalizeTimer: ReturnType | null = null
+ let hardTimer: ReturnType | null = null
+ let noFrameTimer: ReturnType | null = null
+ let settled = false
+ let firstFrame = true
+ let finalFrameSent = false
+ let finalReceived = false
+ let audioSequence = 0
+ let frameCount = 0
+ let totalBytes = 0
+ let transcript = ''
+ let firstPartialAt: number | null = null
+ const transcriptSegments: string[] = []
+
+ const updateCurrent = () => {
+ xunfeiSessionSttRef.current = {
+ socket,
+ frameSubscription,
+ stateSubscription,
+ finalizeTimer,
+ hardTimer,
+ noFrameTimer,
+ settled,
+ }
+ }
+
+ const clearNoFrameTimer = () => {
+ if (!noFrameTimer) return
+ clearTimeout(noFrameTimer)
+ noFrameTimer = null
+ updateCurrent()
+ }
+
+ const settle = (reason: string, submitted: boolean) => {
+ if (settled) return
+ settled = true
+ updateCurrent()
+ if (finalizeTimer) clearTimeout(finalizeTimer)
+ if (hardTimer) clearTimeout(hardTimer)
+ if (noFrameTimer) clearTimeout(noFrameTimer)
+ frameSubscription?.remove()
+ void stopPcmCapture(`session_${reason}`).catch(() => undefined).finally(() => stateSubscription?.remove())
+ if (socket && socket.readyState === WebSocket.OPEN) socket.close()
+ xunfeiSessionSttRef.current = null
+ logVoiceMetric('stt_end', {
+ provider: 'xunfei',
+ cancelled: reason !== 'final',
+ reason,
+ hadTranscript: Boolean(transcript.trim()),
+ submitted,
+ elapsedMs: Date.now() - streamStartedAt,
+ frameCount,
+ totalBytes,
+ })
+ }
+
+ const sendAudioFrame = (status: 0 | 1 | 2, audioBase64: string, session: CreateASRSessionResponse) => {
+ if (!socket || socket.readyState !== WebSocket.OPEN || finalFrameSent) return
+ if (status === 2) finalFrameSent = true
+ audioSequence += 1
+ socket.send(JSON.stringify(createXunfeiASRFrame(session, status, audioBase64, audioSequence)))
+ }
+
+ try {
+ const authReady = await auth.refreshSession()
+ if (!canUseXunfeiRoute('after_auth_refresh')) return false
+ if (!authReady) return nativeSpeechStartListeningRef.current('en-US')
+
+ const session = await api.createASRSession({
+ provider: 'xunfei',
+ mode: 'streaming',
+ languageMode: 'mixed_zh_en',
+ scenarioKey: scenario.key,
+ sessionId: snapshotRef.current.sessionId,
+ endpointSilenceMs: 900,
+ clientTraceId: `mobile-session-${Date.now()}`,
+ })
+ if (!canUseXunfeiRoute('after_session_create')) {
+ logVoiceMetric('stt_start_stale_after_bootstrap', { provider: 'xunfei' })
+ return false
+ }
+ if (session.provider !== 'xunfei' || session.status !== 'created' || session.transport !== 'websocket' || !session.endpointUrl) {
+ logVoiceMetric('stt_provider_fallback', { requested: 'xunfei', reason: 'session_not_ready' })
+ return nativeSpeechStartListeningRef.current('en-US')
+ }
+
+ logVoiceMetric('stt_bootstrap_start', { provider: 'xunfei' })
+ setStatus('session.status.preparing_listening')
+ await nativeSpeechCancelListeningRef.current()
+
+ socket = new WebSocket(session.endpointUrl)
+ updateCurrent()
+
+ const finishAudio = () => {
+ if (!canUseXunfeiRoute('finish_audio')) {
+ settle('route_inactive', false)
+ return
+ }
+ if (finalFrameSent) return
+ sendAudioFrame(2, '', session)
+ void stopPcmCapture('session_endpoint').catch(() => undefined)
+ }
+
+ const scheduleFinalize = () => {
+ if (finalizeTimer) clearTimeout(finalizeTimer)
+ finalizeTimer = setTimeout(finishAudio, session.providerConfig?.eosMs ?? 900)
+ updateCurrent()
+ }
+
+ stateSubscription = addPcmStateListener(event => {
+ logVoiceMetric('stt_pcm_state', {
+ provider: 'xunfei',
+ state: event.state,
+ frameCount: event.frameCount,
+ totalBytes: event.totalBytes,
+ message: event.message,
+ })
+ })
+
+ frameSubscription = addPcmFrameListener((event: PcmCaptureFrameEvent) => {
+ if (!canUseXunfeiRoute('pcm_frame')) {
+ settle('route_inactive', false)
+ return
+ }
+ if (!socket || socket.readyState !== WebSocket.OPEN || finalFrameSent) return
+ frameCount += 1
+ totalBytes += event.byteCount
+ if (frameCount === 1) clearNoFrameTimer()
+ sendAudioFrame(firstFrame ? 0 : 1, event.audioBase64, session)
+ firstFrame = false
+ if (frameCount === 1 || frameCount % 50 === 0) {
+ logVoiceMetric('stt_pcm_frame', {
+ provider: 'xunfei',
+ frameCount,
+ totalBytes,
+ elapsedMs: event.elapsedMs,
+ })
+ }
+ })
+
+ socket.onopen = () => {
+ if (!canUseXunfeiRoute('socket_open')) {
+ settle('route_inactive', false)
+ return
+ }
+ void startPcmCapture({
+ sampleRate: session.providerConfig?.sampleRate ?? 16000,
+ frameDurationMs: session.providerConfig?.frameIntervalMs ?? 40,
+ }).then(status => {
+ if (!canUseXunfeiRoute('after_pcm_start')) {
+ void stopPcmCapture('session_route_inactive').catch(() => undefined)
+ settle('route_inactive', false)
+ return
+ }
+ listeningStartMsRef.current = Date.now()
+ logVoiceMetric('stt_start', { provider: 'xunfei' })
+ logVoiceMetric('stt_ready', {
+ provider: 'xunfei',
+ elapsedMs: Date.now() - streamStartedAt,
+ sampleRate: status.sampleRate,
+ frameSizeBytes: status.frameSizeBytes,
+ })
+ setStatus('session.status.listening')
+ noFrameTimer = setTimeout(() => {
+ if (settled || frameCount > 0) return
+ logVoiceMetric('stt_pcm_no_frame', {
+ provider: 'xunfei',
+ elapsedMs: Date.now() - streamStartedAt,
+ pcmStatusFrameCount: status.frameCount,
+ pcmStatusTotalBytes: status.totalBytes,
+ })
+ settle('pcm_no_frame', false)
+ handleListeningEndedWithoutTranscript()
+ }, 1800)
+ updateCurrent()
+ }).catch(error => {
+ logVoiceMetric('stt_provider_error', {
+ provider: 'xunfei',
+ message: error instanceof Error ? error.message : 'PCM capture failed',
+ })
+ settle('pcm_error', false)
+ handleListeningEndedWithoutTranscript()
+ })
+ hardTimer = setTimeout(finishAudio, 15_000)
+ updateCurrent()
+ }
+
+ socket.onmessage = event => {
+ if (!canUseXunfeiRoute('socket_message')) {
+ settle('route_inactive', false)
+ return
+ }
+ const payload = parseJsonObject(event.data)
+ const header = getObject(payload?.header)
+ const code = typeof header?.code === 'number'
+ ? header.code
+ : typeof payload?.code === 'number'
+ ? payload.code
+ : 0
+ if (code !== 0) {
+ const message = typeof header?.message === 'string'
+ ? header.message
+ : typeof payload?.message === 'string'
+ ? payload.message
+ : `Xunfei ASR error ${code}`
+ logVoiceMetric('stt_provider_error', { provider: 'xunfei', code, message })
+ settle('provider_error', false)
+ handleListeningEndedWithoutTranscript()
+ return
+ }
+
+ const recognitionResult = extractXunfeiRecognitionResult(payload)
+ if (recognitionResult?.text) {
+ if (recognitionResult.pgs === 'rpl' && recognitionResult.rg) {
+ const [start, end] = recognitionResult.rg
+ for (let index = start; index <= end; index += 1) transcriptSegments[index] = ''
+ }
+ if (recognitionResult.sn != null) {
+ transcriptSegments[recognitionResult.sn] = recognitionResult.text
+ } else {
+ transcriptSegments.push(recognitionResult.text)
+ }
+ transcript = transcriptSegments.filter(Boolean).join('').trim()
+ if (!firstPartialAt) {
+ firstPartialAt = Date.now()
+ logVoiceMetric('stt_first_partial', {
+ provider: 'xunfei',
+ elapsedMs: firstPartialAt - streamStartedAt,
+ chars: transcript.length,
+ })
+ }
+ logVoiceMetric('stt_partial', { provider: 'xunfei', chars: transcript.length })
+ scheduleFinalize()
+ }
+
+ const data = getObject(payload?.data)
+ const status = typeof header?.status === 'number'
+ ? header.status
+ : typeof data?.status === 'number'
+ ? data.status
+ : undefined
+ if (status === 2) {
+ finalReceived = true
+ const normalized = transcript.trim()
+ if (normalized) {
+ logVoiceMetric('stt_submit', {
+ provider: 'xunfei',
+ source: 'xunfei_final',
+ chars: normalized.length,
+ elapsedMs: Date.now() - streamStartedAt,
+ })
+ void handleNativeFinalTranscript(normalized)
+ settle('final', true)
+ } else {
+ settle('final', false)
+ handleListeningEndedWithoutTranscript()
+ }
+ }
+ }
+
+ socket.onerror = () => {
+ logVoiceMetric('stt_provider_error', { provider: 'xunfei', message: 'WebSocket error' })
+ settle('socket_error', false)
+ handleListeningEndedWithoutTranscript()
+ }
+
+ socket.onclose = event => {
+ logVoiceMetric('stt_socket_close', {
+ provider: 'xunfei',
+ code: typeof event?.code === 'number' ? event.code : null,
+ reason: typeof event?.reason === 'string' ? event.reason : '',
+ wasClean: Boolean(event?.wasClean),
+ finalReceived,
+ finalFrameSent,
+ frameCount,
+ totalBytes,
+ })
+ if (!finalReceived && !settled) {
+ settle('socket_closed', false)
+ handleListeningEndedWithoutTranscript()
+ }
+ }
+
+ updateCurrent()
+ return true
+ } catch (error) {
+ logVoiceMetric('stt_provider_error', {
+ provider: 'xunfei',
+ message: error instanceof Error ? error.message : 'Xunfei STT failed to start',
+ })
+ settle('start_error', false)
+ return nativeSpeechStartListeningRef.current('en-US')
+ }
+ }, [
+ api, auth, availableSessionSttProviders, handleListeningEndedWithoutTranscript,
+ handleNativeFinalTranscript, logVoiceMetric, scenario.key,
+ ])
+
const speech = useNativeSpeech({
onFinalTranscript: handleNativeFinalTranscript,
onListeningEndedWithoutTranscript: handleListeningEndedWithoutTranscript,
@@ -601,15 +1471,42 @@ function AppInner() {
})
useEffect(() => {
- speechStartListeningRef.current = speech.startListening
- speechCancelListeningRef.current = speech.cancelListening
+ nativeSpeechStartListeningRef.current = speech.startListening
+ nativeSpeechCancelListeningRef.current = speech.cancelListening
}, [speech.cancelListening, speech.startListening])
+ useEffect(() => {
+ speechStartListeningRef.current = sessionSttProvider === 'xunfei'
+ ? startXunfeiSessionListening
+ : speech.startListening
+ speechCancelListeningRef.current = sessionSttProvider === 'xunfei'
+ ? cancelXunfeiSessionListening
+ : speech.cancelListening
+ startListeningWithProviderRef.current = (provider, lang) => (
+ provider === 'xunfei'
+ ? startXunfeiSessionListening()
+ : speech.startListening(lang)
+ )
+ }, [
+ cancelXunfeiSessionListening, sessionSttProvider, speech, speech.cancelListening, speech.startListening,
+ startXunfeiSessionListening,
+ ])
+
useEffect(() => {
sessionActiveRef.current = isSessionActive
}, [isSessionActive])
const selectTab = useCallback((tab: Tab) => {
+ logUserAction('tab_tap', { to: tab, from: activeTabRef.current })
+ logVoiceMetric('tab_change', {
+ from: activeTabRef.current,
+ to: tab,
+ sessionActive: sessionActiveRef.current,
+ canListenOnRoute: canListenOnRouteRef.current,
+ busy,
+ playbackActive: playbackActiveRef.current,
+ audioPlaying: audioPlayingRef.current,
+ })
setActiveTab(tab)
if (tab !== 'session') {
canListenOnRouteRef.current = false
@@ -617,7 +1514,7 @@ function AppInner() {
clearResumeListeningTimer()
endpointRequestRef.current += 1
pendingNativeTranscriptRef.current = ''
- void speechCancelListeningRef.current()
+ void cancelListeningForReason(`tab:${tab}`)
if (sessionActiveRef.current) setStatus('session.paused')
return
}
@@ -625,29 +1522,43 @@ function AppInner() {
canListenOnRouteRef.current = true
if (sessionActiveRef.current && !busy && !playbackActiveRef.current && !audioPlayingRef.current) {
listeningStartMsRef.current = Date.now()
- setStatus('session.status.listening')
+ setStatus(listeningStartupStatus())
void speechStartListeningRef.current('en-US')
}
- }, [busy, clearResumeListeningTimer])
+ }, [busy, cancelListeningForReason, clearResumeListeningTimer, listeningStartupStatus, logUserAction, logVoiceMetric])
async function endSession() {
- if (!canEndSession({ activeSession: isSessionActive, workflowState: snapshot.state }) || busy) return
+ logUserAction('session_stop_tap')
+ if (!canEndSession({ activeSession: isSessionActive, workflowState: snapshot.state })) return
+ logVoiceMetric('session_end_requested', {
+ sessionId: snapshot.sessionId,
+ state: snapshot.state,
+ messages: messages.length,
+ activeTab: activeTabRef.current,
+ pendingTeardown: Boolean(listeningTeardownRef.current),
+ })
// 立即结束 session,不等 API
+ turnRequestRef.current += 1
sessionActiveRef.current = false
canListenOnRouteRef.current = false
playbackActiveRef.current = false
+ audioPlayingRef.current = false
+ playbackStartedRef.current = false
playbackEndedAtMsRef.current = null
clearResumeListeningTimer()
endpointRequestRef.current += 1
pendingNativeTranscriptRef.current = ''
- void speechCancelListeningRef.current()
+ audio.stopPlayback()
+ setAudioUrl(null)
+ setPlaybackQueue(createPlaybackQueueSnapshot())
+ void cancelListeningForReason('session_end')
const endedSnapshot = endActiveSession(snapshot).snapshot
setSnapshot(endedSnapshot)
setIsSessionActive(false)
setStatus('session.ended')
+ setBusy(false)
- setBusy(true)
const userTurns = messages.filter(m => m.role === 'user').length
try {
const result = await api.generateSummary({
@@ -667,25 +1578,60 @@ function AppInner() {
}).catch(() => undefined)
} catch {
// summary 失败不影响 session 已结束的状态
- } finally {
- setBusy(false)
}
}
- function selectScenario(key: string) {
- setSelectedScenarioKey(key)
- setMessages([])
- setCorrectionHistory([])
- setAudioUrl(null)
- setPlaybackQueue(createPlaybackQueueSnapshot())
- setSummary(null)
- setSnapshot(createInitialSnapshot('mobile-session'))
- setIsSessionActive(false)
- setStatus('session.status.scenario_selected')
+ async function selectScenario(key: string) {
+ if (scenarioSwitching) {
+ logUserAction('scenario_tap_ignored_switching', { to: key })
+ return
+ }
+ logUserAction('scenario_tap', { to: key, from: selectedScenarioKey })
+ logVoiceMetric('scenario_select_requested', {
+ from: selectedScenarioKey,
+ to: key,
+ activeTab: activeTabRef.current,
+ sessionActive: sessionActiveRef.current,
+ canListenOnRoute: canListenOnRouteRef.current,
+ pendingTeardown: Boolean(listeningTeardownRef.current),
+ })
+ setScenarioSwitching(true)
+ setStatus('session.status.switching_session')
+ try {
+ turnRequestRef.current += 1
+ endpointRequestRef.current += 1
+ sessionActiveRef.current = false
+ canListenOnRouteRef.current = false
+ playbackActiveRef.current = false
+ audioPlayingRef.current = false
+ playbackStartedRef.current = false
+ playbackEndedAtMsRef.current = null
+ clearResumeListeningTimer()
+ pendingNativeTranscriptRef.current = ''
+ audio.stopPlayback()
+ setBusy(false)
+ await cancelListeningForReason('scenario_change')
+ setSelectedScenarioKey(key)
+ setMessages([])
+ setCorrectionHistory([])
+ setAudioUrl(null)
+ setPlaybackQueue(createPlaybackQueueSnapshot())
+ setSummary(null)
+ setSnapshot(createInitialSnapshot('mobile-session'))
+ setIsSessionActive(false)
+ setStatus('session.status.scenario_selected')
+ logVoiceMetric('scenario_selected', { key })
+ } finally {
+ setScenarioSwitching(false)
+ }
}
- async function loadHistory() {
+ const loadHistory = useCallback(async () => {
if (historyLoading) return
+ if (auth.state !== 'signed-in') {
+ setHistoryError(tr('history.auth_required'))
+ return
+ }
setHistoryLoading(true)
setHistoryError(null)
try {
@@ -694,21 +1640,37 @@ function AppInner() {
setSelectedHistory(result.sessions[0] ?? null)
setSelectedHistoryTurns([])
} catch (error) {
- const message = error instanceof MeteorVoiceApiError
- ? `${error.message} (${error.status})`
- : error instanceof Error ? error.message : 'History request failed'
- setHistoryError(message)
+ const requestError = formatApiRequestError(error, {
+ context: 'mobile_history_list',
+ presentation: 'inline',
+ })
+ setHistoryError(requestError.displayMessage)
} finally {
setHistoryLoading(false)
}
- }
+ }, [api, auth.state, historyLoading, tr])
+
+ useEffect(() => {
+ if (activeTab !== 'history') return
+ if (auth.state !== 'signed-in') {
+ historyAutoLoadRef.current = false
+ return
+ }
+ if (historyAutoLoadRef.current) return
+ historyAutoLoadRef.current = true
+ void loadHistory()
+ }, [activeTab, auth.state, loadHistory])
async function deleteSession(id: string) {
setHistorySessions(prev => prev.map(s => s.id === id ? { ...s, status: 'deleted' } : s))
try {
- await fetch(`${apiBaseUrl.trim()}/api/session?id=${encodeURIComponent(id)}`, {
+ const authHeaders = await getAuthHeaders()
+ await fetchWithTimeout(fetch, `${apiBaseUrl.trim()}/api/session?id=${encodeURIComponent(id)}`, {
method: 'DELETE',
- headers: getAuthHeaders() as Record,
+ headers: authHeaders as Record,
+ }).then(res => {
+ if (res.status === 401) return handleUnauthorized()
+ return undefined
})
} catch {
// 静默失败
@@ -722,103 +1684,290 @@ function AppInner() {
const result = await api.listSessionTurns(item.id)
setSelectedHistoryTurns(result.turns)
} catch (error) {
- const message = error instanceof MeteorVoiceApiError
- ? `${error.message} (${error.status})`
- : error instanceof Error ? error.message : 'Turn detail request failed'
- setHistoryError(message)
+ const requestError = formatApiRequestError(error, {
+ context: 'mobile_history_turns',
+ presentation: 'inline',
+ })
+ setHistoryError(requestError.displayMessage)
}
}
- async function loadPreferences() {
- if (settingsLoading) return
- setSettingsLoading(true)
+ const applyPreferences = useCallback((preferences: PreferencesResponse, successMessage?: string) => {
+ setLocale(preferences.locale === 'zh' ? 'zh' : 'en')
+ setTtsProvider(preferences.tts_provider ?? 'mock')
+ setAvailableProviders(preferences.available_providers?.length ? preferences.available_providers : ['mock'])
+ setTtsSpeed(preferences.tts_speed ?? 1)
+ if (preferences.tts_voice_id !== undefined) setTtsVoiceId(preferences.tts_voice_id)
+ if (preferences.voice_profiles) setVoiceProfiles(preferences.voice_profiles)
+ if (preferences.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(preferences.selected_voice_profile_id)
+ if (preferences.xunfei_voices?.configured) setXunfeiVoices(preferences.xunfei_voices.configured)
+ if (preferences.default_scenario_key) setSelectedScenarioKey(preferences.default_scenario_key)
+ const profile = preferences.voice_profiles?.find(item => item.id === preferences.selected_voice_profile_id)
+ if (profile) setSelectedAccentKey(profile.accentKey)
+ if (preferences.ui_theme && !themeInitializedRef.current) {
+ themeInitializedRef.current = true
+ void SecureStore.getItemAsync('theme_set_at').then(localSetAt => {
+ const serverTs = new Date(preferences.ui_theme_updated_at ?? new Date(0).toISOString()).getTime()
+ const localTs = localSetAt ? new Date(localSetAt).getTime() : 0
+ if (serverTs >= localTs) {
+ applyThemeLocal(preferences.ui_theme as Parameters[0])
+ }
+ })
+ }
+ setSettingsMessage(successMessage ?? tr('session.status.preferences_loaded'))
+ }, [applyThemeLocal, setLocale, tr])
+
+ const applyTtsPreferences = useCallback((preferences: PreferencesResponse, successMessage = tr('session.status.preferences_saved')) => {
+ setTtsProvider(preferences.tts_provider ?? 'mock')
+ setTtsSpeed(preferences.tts_speed ?? 1)
+ if (preferences.tts_voice_id !== undefined) setTtsVoiceId(preferences.tts_voice_id)
+ if (preferences.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(preferences.selected_voice_profile_id)
+ const profiles = preferences.voice_profiles ?? voiceProfiles
+ const profile = profiles.find(item => item.id === preferences.selected_voice_profile_id)
+ if (profile) setSelectedAccentKey(profile.accentKey)
+ setSettingsMessage(successMessage)
+ }, [tr, voiceProfiles])
+
+ const applyPracticePreferences = useCallback((preferences: PreferencesResponse, successMessage = tr('session.status.practice_defaults_saved')) => {
+ setTtsProvider(preferences.tts_provider ?? 'mock')
+ setTtsSpeed(preferences.tts_speed ?? 1)
+ if (preferences.default_scenario_key) setSelectedScenarioKey(preferences.default_scenario_key)
+ setSettingsMessage(successMessage)
+ }, [tr])
+
+ const applyVoiceProfilePreferences = useCallback((preferences: PreferencesResponse, successMessage = tr('session.status.preferences_saved')) => {
+ setTtsProvider(preferences.tts_provider ?? 'mock')
+ if (preferences.tts_voice_id !== undefined) setTtsVoiceId(preferences.tts_voice_id)
+ if (preferences.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(preferences.selected_voice_profile_id)
+ const profiles = preferences.voice_profiles ?? voiceProfiles
+ const profile = profiles.find(item => item.id === preferences.selected_voice_profile_id)
+ if (profile) setSelectedAccentKey(profile.accentKey)
+ setSettingsMessage(successMessage)
+ }, [tr, voiceProfiles])
+
+ const applyLocalePreferences = useCallback((preferences: PreferencesResponse, successMessage = tr('session.status.preferences_saved')) => {
+ setLocale(preferences.locale === 'zh' ? 'zh' : 'en')
+ setSettingsMessage(successMessage)
+ }, [setLocale, tr])
+
+ const fetchSessionSttProviders = useCallback(async () => {
+ const result = await api.listASRProviders()
+ const providers: SessionSttProvider[] = ['native']
+ if (result.providers.some(provider => provider.key === 'xunfei' && provider.enabled)) {
+ providers.push('xunfei')
+ }
+ return providers
+ }, [api])
+
+ const applySessionSttProviders = useCallback((providers: SessionSttProvider[]) => {
+ setAvailableSessionSttProviders(providers)
+ if (!providers.includes(sessionSttProvider)) {
+ sessionSttProviderRef.current = 'native'
+ setSessionSttProviderState('native')
+ void SecureStore.setItemAsync(sessionSttProviderStorageKey, 'native')
+ }
+ }, [sessionSttProvider])
+
+ const loadSessionSttProviders = useCallback(async () => {
+ try {
+ const providers = await fetchSessionSttProviders()
+ applySessionSttProviders(providers)
+ sessionSttProvidersLoadedRef.current = true
+ } catch (error) {
+ const requestError = formatApiRequestError(error, {
+ context: 'mobile_asr_providers_load',
+ presentation: 'silent',
+ })
+ logVoiceMetric('mobile_silent_request_error', requestError.logData)
+ }
+ }, [applySessionSttProviders, fetchSessionSttProviders, logVoiceMetric])
+
+ useEffect(() => appFeedback.subscribe(setActiveFeedback), [])
+
+ useEffect(() => {
+ if (settingsLoading && activeTab === 'settings') {
+ appFeedback.show({
+ message: tr('settings.syncing'),
+ variant: 'hud',
+ source: 'settings',
+ })
+ return
+ }
+ appFeedback.hide('settings')
+ }, [activeTab, settingsLoading, tr])
+
+ useEffect(() => {
+ if (authSubmitting && activeTab === 'settings') {
+ appFeedback.show({
+ message: tr('login.loading'),
+ variant: 'hud',
+ source: 'auth',
+ })
+ return
+ }
+ appFeedback.hide('auth')
+ }, [activeTab, authSubmitting, tr])
+
+ useEffect(() => {
+ if (scenarioSwitching) {
+ appFeedback.show({
+ message: tr('session.status.switching_session'),
+ variant: 'hud',
+ source: 'session-transition',
+ })
+ return
+ }
+ appFeedback.hide('session-transition')
+ }, [scenarioSwitching, tr])
+
+ const loadPreferences = useCallback(async (options: { force?: boolean; successMessage?: string } = {}) => {
+ if (settingsLoadingRef.current && !options.force) return
+ if (auth.state !== 'signed-in') {
+ setSettingsMessage(tr('settings.auth_required'))
+ return
+ }
+ const requestId = ++settingsRequestRef.current
+ setSettingsLoadingFlag(true)
setSettingsMessage(null)
try {
const preferences = await api.getPreferences()
- setLocale(preferences.locale === 'zh' ? 'zh' : 'en')
- setTtsProvider(preferences.tts_provider ?? 'mock')
- setAvailableProviders(preferences.available_providers?.length ? preferences.available_providers : ['mock'])
- setTtsSpeed(preferences.tts_speed ?? 1)
- if (preferences.tts_voice_id !== undefined) setTtsVoiceId(preferences.tts_voice_id)
- if (preferences.voice_profiles) setVoiceProfiles(preferences.voice_profiles)
- if (preferences.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(preferences.selected_voice_profile_id)
- if (preferences.xunfei_voices?.configured) setXunfeiVoices(preferences.xunfei_voices.configured)
- if (preferences.default_scenario_key) setSelectedScenarioKey(preferences.default_scenario_key)
- const profile = preferences.voice_profiles?.find(item => item.id === preferences.selected_voice_profile_id)
- if (profile) setSelectedAccentKey(profile.accentKey)
- setSettingsMessage(tr('session.status.preferences_loaded'))
+ if (requestId !== settingsRequestRef.current) return
+ applyPreferences(preferences, options.successMessage)
} catch (error) {
- const message = error instanceof MeteorVoiceApiError
- ? `${error.message} (${error.status})`
- : error instanceof Error ? error.message : 'Preferences request failed'
- setSettingsMessage(message)
+ const requestError = formatApiRequestError(error, {
+ context: 'mobile_preferences_load',
+ presentation: 'inline',
+ })
+ setSettingsMessage(requestError.displayMessage)
} finally {
- setSettingsLoading(false)
+ if (requestId === settingsRequestRef.current) {
+ setSettingsLoadingFlag(false)
+ }
}
- }
+ }, [api, applyPreferences, auth.state, setSettingsLoadingFlag, tr])
+
+ const loadSettingsDataGroup = useCallback(() => {
+ if (settingsLoadingRef.current) return () => undefined
+ if (auth.state !== 'signed-in') {
+ setSettingsMessage(tr('settings.auth_required'))
+ return () => undefined
+ }
+
+ let cancelled = false
+ const requestId = ++settingsRequestRef.current
+ setSettingsLoadingFlag(true)
+ setSettingsMessage(null)
+
+ void runAppOperationGroup({
+ source: 'mobile_settings_data',
+ tasks: {
+ preferences: () => api.getPreferences(),
+ providers: fetchSessionSttProviders,
+ },
+ }).then(({ preferences: preferencesResult, providers: providersResult }) => {
+ if (cancelled || requestId !== settingsRequestRef.current) return
+
+ if (preferencesResult.status === 'fulfilled') {
+ applyPreferences(preferencesResult.value)
+ } else {
+ const requestError = formatApiRequestError(preferencesResult.reason, {
+ context: 'mobile_preferences_load',
+ presentation: 'inline',
+ })
+ setSettingsMessage(requestError.displayMessage)
+ }
+
+ if (providersResult.status === 'fulfilled') {
+ applySessionSttProviders(providersResult.value)
+ } else {
+ const requestError = formatApiRequestError(providersResult.reason, {
+ context: 'mobile_asr_providers_load',
+ presentation: 'silent',
+ })
+ logVoiceMetric('mobile_silent_request_error', requestError.logData)
+ }
+ }).finally(() => {
+ if (!cancelled && requestId === settingsRequestRef.current) {
+ setSettingsLoadingFlag(false)
+ }
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [
+ api, applyPreferences, applySessionSttProviders, auth.state,
+ fetchSessionSttProviders, logVoiceMetric, setSettingsLoadingFlag, tr,
+ ])
+
+ const reloadSettingsData = useCallback(() => loadSettingsDataGroup(), [loadSettingsDataGroup])
+
+ useEffect(() => {
+ if (auth.state !== 'signed-in') {
+ settingsAutoLoadRef.current = false
+ return
+ }
+ if (activeTab !== 'settings' || settingsAutoLoadRef.current) return
+ settingsAutoLoadRef.current = true
+ return reloadSettingsData()
+ }, [activeTab, auth.state, reloadSettingsData])
async function saveProvider(provider: string) {
+ settingsRequestRef.current += 1
setTtsProvider(provider)
setAudioUrl(null)
playbackEndedAtMsRef.current = null
- setSettingsLoading(true)
+ setSettingsLoadingFlag(true)
setSettingsMessage(null)
if (auth.state !== 'signed-in') {
setSettingsMessage(tr('session.status.preferences_saved'))
- setSettingsLoading(false)
+ setSettingsLoadingFlag(false)
return
}
try {
- const result = await api.updatePreferences({
+ const preferences = await api.updatePreferences({
tts_provider: provider,
default_scenario_key: selectedScenarioKey,
tts_speed: ttsSpeed,
})
- setTtsProvider(result.tts_provider)
- setTtsSpeed(result.tts_speed)
- if (result.voice_profiles) setVoiceProfiles(result.voice_profiles)
- if (result.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(result.selected_voice_profile_id)
- const profile = result.voice_profiles?.find(item => item.id === result.selected_voice_profile_id)
- if (profile) setSelectedAccentKey(profile.accentKey)
- setSettingsMessage(tr('session.status.preferences_saved'))
+ applyTtsPreferences(preferences)
} catch (error) {
- const message = error instanceof MeteorVoiceApiError
- ? `${error.message} (${error.status})`
- : error instanceof Error ? error.message : 'Preferences save failed'
- setSettingsMessage(message)
+ const requestError = formatApiRequestError(error, {
+ context: 'mobile_preferences_save_provider',
+ presentation: 'inline',
+ })
+ setSettingsMessage(requestError.displayMessage)
} finally {
- setSettingsLoading(false)
+ setSettingsLoadingFlag(false)
}
}
async function savePracticePreferences() {
- setSettingsLoading(true)
+ settingsRequestRef.current += 1
+ setSettingsLoadingFlag(true)
setSettingsMessage(null)
if (auth.state !== 'signed-in') {
setSettingsMessage(tr('session.status.practice_defaults_saved'))
- setSettingsLoading(false)
+ setSettingsLoadingFlag(false)
return
}
try {
- const result = await api.updatePreferences({
+ const preferences = await api.updatePreferences({
tts_provider: ttsProvider,
default_scenario_key: selectedScenarioKey,
tts_speed: ttsSpeed,
})
- setTtsProvider(result.tts_provider)
- setTtsSpeed(result.tts_speed)
- if (result.voice_profiles) setVoiceProfiles(result.voice_profiles)
- if (result.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(result.selected_voice_profile_id)
- setSettingsMessage(tr('session.status.practice_defaults_saved'))
+ applyPracticePreferences(preferences)
} catch (error) {
- const message = error instanceof MeteorVoiceApiError
- ? `${error.message} (${error.status})`
- : error instanceof Error ? error.message : 'Preferences save failed'
- setSettingsMessage(message)
+ const requestError = formatApiRequestError(error, {
+ context: 'mobile_preferences_save_practice',
+ presentation: 'inline',
+ })
+ setSettingsMessage(requestError.displayMessage)
} finally {
- setSettingsLoading(false)
+ setSettingsLoadingFlag(false)
}
}
@@ -830,9 +1979,14 @@ function AppInner() {
void syncMobilePreferences({
apiBaseUrl: apiBaseUrl.trim(),
getAuthHeaders: auth.getAuthHeaders,
+ onUnauthorized: handleUnauthorized,
ttsSpeed: next,
ttsProvider,
defaultScenarioKey: selectedScenarioKey,
+ }).then(preferences => {
+ if (preferences) {
+ applyTtsPreferences(preferences)
+ }
})
}, 600)
return next
@@ -841,6 +1995,7 @@ function AppInner() {
async function selectVoiceProfile(profile: VoiceProfile) {
if (profile.status !== 'active') return
+ settingsRequestRef.current += 1
setAudioUrl(null)
playbackEndedAtMsRef.current = null
setSelectedVoiceProfileId(profile.id)
@@ -851,106 +2006,129 @@ function AppInner() {
if (auth.state !== 'signed-in') return
try {
- const result = await api.updatePreferences({ selected_voice_profile_id: profile.id })
- setTtsProvider(result.tts_provider)
- setTtsVoiceId(result.tts_voice_id)
- setSelectedVoiceProfileId(result.selected_voice_profile_id)
- if (result.voice_profiles) setVoiceProfiles(result.voice_profiles)
- } catch {
- // 静默失败
+ const preferences = await api.updatePreferences({ selected_voice_profile_id: profile.id })
+ applyVoiceProfilePreferences(preferences)
+ } catch (error) {
+ const requestError = formatApiRequestError(error, {
+ context: 'mobile_preferences_select_voice_profile',
+ presentation: 'silent',
+ })
+ logVoiceMetric('mobile_silent_request_error', requestError.logData)
}
}
- async function submitAuth() {
- const normalizedEmail = email.trim()
- if (!normalizedEmail || !password || auth.state === 'loading') return
- const success = await auth.submit(authMode, normalizedEmail, password)
- if (success) setPassword('')
- }
+ async function saveLocalePreference(nextLocale: Locale) {
+ if (nextLocale === locale) return
+ setLocale(nextLocale)
- const applyPrefs = useCallback((prefs: Awaited>) => {
- if (!prefs) return
- setTtsProvider(prefs.ttsProvider)
- setTtsSpeed(prefs.ttsSpeed)
- setAvailableProviders(prefs.availableProviders)
- setTtsVoiceId(prefs.ttsVoiceId)
- setVoiceProfiles(prefs.voiceProfiles)
- setSelectedVoiceProfileId(prefs.selectedVoiceProfileId)
- if (prefs.xunfeiVoices.length > 0) setXunfeiVoices(prefs.xunfeiVoices)
- if (prefs.defaultScenarioKey) setSelectedScenarioKey(prefs.defaultScenarioKey)
- const profile = prefs.voiceProfiles.find(item => item.id === prefs.selectedVoiceProfileId)
- if (profile) setSelectedAccentKey(profile.accentKey)
- if (prefs.locale === 'zh' || prefs.locale === 'en') setLocale(prefs.locale)
- if (prefs.uiTheme && !themeInitializedRef.current) {
- themeInitializedRef.current = true
- void SecureStore.getItemAsync('theme_set_at').then(localSetAt => {
- const serverTs = new Date(prefs.uiThemeUpdatedAt).getTime()
- const localTs = localSetAt ? new Date(localSetAt).getTime() : 0
- if (serverTs >= localTs) {
- applyThemeLocal(prefs.uiTheme as Parameters[0])
- }
+ if (auth.state !== 'signed-in') {
+ setSettingsMessage(tr('settings.auth_required'))
+ return
+ }
+
+ const requestId = ++settingsRequestRef.current
+ setSettingsLoadingFlag(true)
+ setSettingsMessage(null)
+ try {
+ const preferences = await api.updatePreferences({ locale: nextLocale })
+ if (requestId !== settingsRequestRef.current) return
+ applyLocalePreferences(preferences)
+ } catch (error) {
+ const requestError = formatApiRequestError(error, {
+ context: 'mobile_preferences_save_locale',
+ presentation: 'banner',
})
+ setSettingsMessage(requestError.displayMessage)
+ displayErrorFeedback(requestError, 'mobile_preferences_save_locale')
+ } finally {
+ if (requestId === settingsRequestRef.current) {
+ setSettingsLoadingFlag(false)
+ }
}
- }, [applyThemeLocal, setLocale])
+ }
- const applyServerPreferences = useCallback((preferences: PreferencesResponse) => {
- setTtsProvider(preferences.tts_provider ?? 'mock')
- setAvailableProviders(preferences.available_providers?.length ? preferences.available_providers : ['mock'])
- setTtsSpeed(preferences.tts_speed ?? 1)
- if (preferences.tts_voice_id !== undefined) setTtsVoiceId(preferences.tts_voice_id)
- if (preferences.voice_profiles) setVoiceProfiles(preferences.voice_profiles)
- if (preferences.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(preferences.selected_voice_profile_id)
- if (preferences.xunfei_voices?.configured) setXunfeiVoices(preferences.xunfei_voices.configured)
- if (preferences.default_scenario_key) setSelectedScenarioKey(preferences.default_scenario_key)
- const profile = preferences.voice_profiles?.find(item => item.id === preferences.selected_voice_profile_id)
- if (profile) setSelectedAccentKey(profile.accentKey)
- if (preferences.locale === 'zh' || preferences.locale === 'en') setLocale(preferences.locale)
- }, [setLocale])
+ async function submitAuth() {
+ const normalizedEmail = email.trim()
+ if (!normalizedEmail || !password || auth.state === 'loading' || authSubmitting) return
+ logUserAction('auth_submit_tap', {
+ mode: authMode,
+ hasEmail: Boolean(normalizedEmail),
+ passwordLength: password.length,
+ })
+ setAuthSubmitting(true)
+ try {
+ const success = await auth.submit(authMode, normalizedEmail, password)
+ logVoiceMetric('auth_submit_done', { mode: authMode, success })
+ if (success) setPassword('')
+ } catch (error) {
+ logVoiceMetric('auth_submit_error', {
+ mode: authMode,
+ message: error instanceof Error ? error.message : 'Auth submit failed',
+ })
+ } finally {
+ setAuthSubmitting(false)
+ }
+ }
useEffect(() => {
- void api.getPreferences()
- .then(applyServerPreferences)
- .catch(() => undefined)
- }, [api, applyServerPreferences])
+ const timer = setTimeout(() => {
+ void loadSessionSttProviders()
+ }, 0)
+ return () => clearTimeout(timer)
+ }, [loadSessionSttProviders])
// 登录后自动拉取偏好
useEffect(() => {
if (auth.state !== 'signed-in') return
- void pullMobilePreferences(apiBaseUrl.trim(), auth.getAuthHeaders).then(applyPrefs)
- }, [auth.state, apiBaseUrl, auth.getAuthHeaders, applyPrefs])
+ const timer = setTimeout(() => {
+ loadSettingsDataGroup()
+ }, 0)
+ return () => clearTimeout(timer)
+ }, [auth.state, loadSettingsDataGroup])
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
if (nextState !== 'active') {
+ logVoiceMetric('app_state_inactive', {
+ nextState,
+ sessionActive: sessionActiveRef.current,
+ activeTab: activeTabRef.current,
+ })
canListenOnRouteRef.current = false
playbackEndedAtMsRef.current = null
clearResumeListeningTimer()
endpointRequestRef.current += 1
pendingNativeTranscriptRef.current = ''
- void speechCancelListeningRef.current()
+ void cancelListeningForReason(`app_state:${nextState}`)
if (sessionActiveRef.current) setStatus('session.paused')
return
}
canListenOnRouteRef.current = true
+ logVoiceMetric('app_state_active', {
+ sessionActive: sessionActiveRef.current,
+ activeTab: activeTabRef.current,
+ busy,
+ playbackActive: playbackActiveRef.current,
+ audioPlaying: audioPlayingRef.current,
+ })
if (auth.state === 'signed-in') {
- void pullMobilePreferences(apiBaseUrl.trim(), auth.getAuthHeaders).then(applyPrefs)
- } else {
- void api.getPreferences().then(applyServerPreferences).catch(() => undefined)
+ void loadPreferences()
}
if (sessionActiveRef.current && !busy && !playbackActiveRef.current && !audioPlayingRef.current) {
listeningStartMsRef.current = Date.now()
- setStatus('session.status.listening')
+ setStatus(listeningStartupStatus())
void speechStartListeningRef.current('en-US')
}
})
return () => subscription.remove()
- }, [api, apiBaseUrl, applyPrefs, applyServerPreferences, auth.getAuthHeaders, auth.state, busy, clearResumeListeningTimer])
+ }, [auth.state, busy, cancelListeningForReason, clearResumeListeningTimer, listeningStartupStatus, loadPreferences, logVoiceMetric])
function playCorrection(text: string) {
+ logUserAction('play_correction_tap', { chars: text.length })
clearResumeListeningTimer()
- void speechCancelListeningRef.current()
+ void cancelListeningForReason('play_correction')
void synthesizeCoachSpeech(text).then(voice => {
if (voice.audioUrl) {
isCorrectionPlayingRef.current = true
@@ -985,7 +2163,10 @@ function AppInner() {
onStart={startSession}
onEnd={() => void endSession()}
onPlayCorrection={playCorrection}
- onSubmitText={text => void submitTurn(text)}
+ onSubmitText={text => {
+ logUserAction('manual_text_submit', { chars: text.trim().length })
+ void submitTurn(text)
+ }}
/>
)
case 'home':
@@ -996,6 +2177,7 @@ function AppInner() {
scenarios={scenarios}
selectedScenarioKey={selectedScenarioKey}
isSessionActive={isSessionActive}
+ scenarioSwitching={scenarioSwitching}
onSelectScenario={selectScenario}
onGoToSession={() => selectTab('session')}
/>
@@ -1022,40 +2204,95 @@ function AppInner() {
locale={locale}
ttsProvider={ttsProvider}
availableProviders={availableProviders}
+ sessionSttProvider={sessionSttProvider}
+ availableSessionSttProviders={availableSessionSttProviders}
ttsSpeed={ttsSpeed}
ttsVoiceId={ttsVoiceId}
voiceProfiles={voiceProfiles}
selectedVoiceProfileId={selectedVoiceProfileId}
xunfeiVoices={xunfeiVoices}
settingsLoading={settingsLoading}
+ authSubmitting={authSubmitting}
settingsMessage={settingsMessage}
auth={auth}
email={email}
password={password}
authMode={authMode}
apiBaseUrl={apiBaseUrl}
+ apiBaseUrlSource={apiBaseUrlSource}
+ defaultApiBaseUrl={defaultApiBaseUrl}
appVersion={appVersion}
voiceMetricsText={voiceMetricsText}
- onSetLocale={l => setLocale(l as Locale)}
- onSetTheme={setTheme}
- onSaveProvider={p => void saveProvider(p)}
- onAdjustSpeed={adjustSpeed}
- onSavePracticePreferences={() => void savePracticePreferences()}
- onLoadPreferences={() => void loadPreferences()}
- onSelectVoiceProfile={profile => void selectVoiceProfile(profile)}
+ asrEvaluationText={asrEvaluationText}
+ onSetLocale={localeValue => {
+ logUserAction('settings_locale_tap', { locale: localeValue })
+ void saveLocalePreference(localeValue as Locale)
+ }}
+ onSetTheme={key => {
+ logUserAction('settings_theme_tap', { theme: key })
+ setTheme(key)
+ }}
+ onSaveProvider={provider => {
+ logUserAction('settings_tts_provider_tap', { provider })
+ void saveProvider(provider)
+ }}
+ onSetSessionSttProvider={provider => {
+ logUserAction('settings_stt_provider_tap', { provider })
+ setSessionSttProvider(provider)
+ }}
+ onAdjustSpeed={delta => {
+ logUserAction('settings_tts_speed_tap', { delta })
+ adjustSpeed(delta)
+ }}
+ onSavePracticePreferences={() => {
+ logUserAction('settings_save_preferences_tap')
+ void savePracticePreferences()
+ }}
+ onLoadPreferences={() => {
+ logUserAction('settings_reload_tap')
+ reloadSettingsData()
+ }}
+ onSelectVoiceProfile={profile => {
+ logUserAction('settings_voice_profile_tap', { profileId: profile.id, provider: profile.provider })
+ void selectVoiceProfile(profile)
+ }}
onSetEmail={setEmail}
onSetPassword={setPassword}
- onSetAuthMode={setAuthMode}
+ onSetAuthMode={mode => {
+ logUserAction('auth_mode_tap', { mode })
+ setAuthMode(mode)
+ }}
onSubmitAuth={() => void submitAuth()}
- onSignOut={() => void auth.signOut()}
- onSetApiBaseUrl={updateApiBaseUrl}
- onClearVoiceMetrics={() => setVoiceMetrics([])}
+ onSignOut={() => {
+ logUserAction('auth_sign_out_tap')
+ void auth.signOut()
+ }}
+ onSetApiBaseUrl={value => {
+ logUserAction('settings_api_base_url_edit', { hasValue: Boolean(value.trim()) })
+ updateApiBaseUrl(value)
+ }}
+ onResetApiBaseUrl={() => {
+ logUserAction('settings_api_base_url_reset_tap')
+ resetApiBaseUrl()
+ }}
+ onClearVoiceMetrics={() => {
+ logUserAction('diagnostics_clear_tap')
+ setVoiceMetrics([])
+ }}
onShareVoiceMetrics={() => {
+ logUserAction('diagnostics_share_tap', { entries: voiceMetrics.length })
void Share.share({
title: 'MeteorVoice voice diagnostics',
message: voiceMetricsText || 'No voice metrics yet.',
})
}}
+ onShareASREvaluation={() => {
+ logUserAction('diagnostics_asr_share_tap', { entries: voiceMetrics.length })
+ void Share.share({
+ title: 'MeteorVoice ASR P4 evaluation',
+ message: asrEvaluationText,
+ })
+ }}
/>
)
}
@@ -1065,7 +2302,10 @@ function AppInner() {
return (
- {renderScreen()}
+
+ {renderScreen()}
+
+
{(['home', 'session', 'history', 'settings'] as Tab[]).map(tab => (
diff --git a/apps/mobile/src/components/AppFeedbackOverlay.tsx b/apps/mobile/src/components/AppFeedbackOverlay.tsx
new file mode 100644
index 0000000..f7b9769
--- /dev/null
+++ b/apps/mobile/src/components/AppFeedbackOverlay.tsx
@@ -0,0 +1,74 @@
+import { useEffect } from 'react'
+import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
+import { hideAppFeedback, type AppFeedbackState } from '@meteorvoice/shared'
+import { useTheme } from '../ThemeProvider'
+
+type Props = {
+ feedback: AppFeedbackState | null
+}
+
+export function AppFeedbackOverlay({ feedback }: Props) {
+ const { C } = useTheme()
+ useEffect(() => {
+ if (!feedback?.active || !feedback.autoDismissMs) return
+ const timer = setTimeout(() => {
+ hideAppFeedback(feedback.source)
+ }, feedback.autoDismissMs)
+ return () => clearTimeout(timer)
+ }, [feedback?.active, feedback?.autoDismissMs, feedback?.source])
+
+ if (!feedback?.active) return null
+
+ const variant = feedback.variant ?? 'hud'
+ const blocksInteraction = feedback.blocksInteraction ?? true
+ const styles = StyleSheet.create({
+ overlay: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: variant === 'bar' ? 'transparent' : 'rgba(0,0,0,0.28)',
+ alignItems: 'center',
+ justifyContent: variant === 'bar' ? 'flex-start' : 'center',
+ paddingHorizontal: 24,
+ paddingTop: 18,
+ },
+ surface: {
+ minWidth: variant === 'bar' ? '100%' : undefined,
+ backgroundColor: C.surface,
+ borderRadius: variant === 'bar' ? 8 : 10,
+ borderWidth: 1,
+ borderColor: C.border,
+ alignItems: 'center',
+ flexDirection: variant === 'panel' ? 'column' : 'row',
+ gap: 10,
+ paddingHorizontal: 18,
+ paddingVertical: 14,
+ },
+ message: {
+ color: C.textPrimary,
+ fontSize: 14,
+ fontWeight: '700',
+ textAlign: 'center',
+ },
+ title: {
+ color: C.textPrimary,
+ fontSize: 15,
+ fontWeight: '800',
+ textAlign: 'center',
+ },
+ textStack: {
+ gap: 3,
+ flexShrink: 1,
+ },
+ })
+
+ return (
+
+
+
+
+ {feedback.title ? {feedback.title} : null}
+ {feedback.message}
+
+
+
+ )
+}
diff --git a/apps/mobile/src/mobileAuth.ts b/apps/mobile/src/mobileAuth.ts
index 7f92b3c..819b9d0 100644
--- a/apps/mobile/src/mobileAuth.ts
+++ b/apps/mobile/src/mobileAuth.ts
@@ -1,4 +1,5 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { Platform, Settings } from 'react-native'
import { createClient, type Session, type SupabaseClient, type User } from '@supabase/supabase-js'
import * as SecureStore from 'expo-secure-store'
@@ -15,9 +16,25 @@ export type MobileAuthState = ReturnType
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY
+const authStorageKey = 'meteorvoice-mobile-supabase-auth'
+const installMarkerKey = 'meteorvoice.installation_marker.v1'
+const installMarkerValue = 'installed'
+
+let shouldClearPersistedAuth = false
+if (Platform.OS === 'ios' && Settings.get(installMarkerKey) !== installMarkerValue) {
+ shouldClearPersistedAuth = true
+ Settings.set({ [installMarkerKey]: installMarkerValue })
+}
const secureStorage = {
- getItem: (key: string) => SecureStore.getItemAsync(key),
+ getItem: async (key: string) => {
+ if (shouldClearPersistedAuth && key === authStorageKey) {
+ shouldClearPersistedAuth = false
+ await SecureStore.deleteItemAsync(key)
+ return null
+ }
+ return SecureStore.getItemAsync(key)
+ },
setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value),
removeItem: (key: string) => SecureStore.deleteItemAsync(key),
}
@@ -27,6 +44,7 @@ export function useMobileAuth() {
const [user, setUser] = useState(null)
const [state, setState] = useState(supabaseUrl && supabaseAnonKey ? 'loading' : 'unconfigured')
const [message, setMessage] = useState(null)
+ const sessionRef = useRef(null)
const client = useMemo(() => {
if (!supabaseUrl || !supabaseAnonKey) return null
@@ -36,6 +54,7 @@ export function useMobileAuth() {
autoRefreshToken: true,
detectSessionInUrl: false,
persistSession: true,
+ storageKey: authStorageKey,
storage: secureStorage,
},
})
@@ -55,12 +74,14 @@ export function useMobileAuth() {
return
}
+ sessionRef.current = data.session
setSession(data.session)
setUser(data.session?.user ?? null)
setState(data.session ? 'signed-in' : 'signed-out')
})
const { data: { subscription } } = client.auth.onAuthStateChange((_event, nextSession) => {
+ sessionRef.current = nextSession
setSession(nextSession)
setUser(nextSession?.user ?? null)
setState(nextSession ? 'signed-in' : 'signed-out')
@@ -94,6 +115,7 @@ export function useMobileAuth() {
return false
}
+ sessionRef.current = result.data.session
setSession(result.data.session)
setUser(result.data.user)
setState(result.data.session ? 'signed-in' : 'signed-out')
@@ -107,22 +129,71 @@ export function useMobileAuth() {
}
}, [client])
- const signOut = useCallback(async () => {
+ const signOut = useCallback(async (nextMessage: string | null = null) => {
if (!client) return
setState('loading')
await client.auth.signOut()
+ sessionRef.current = null
setSession(null)
setUser(null)
setState('signed-out')
+ setMessage(nextMessage)
+ }, [client])
+
+ const refreshSession = useCallback(async () => {
+ if (!client) {
+ setState('unconfigured')
+ setMessage('Set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY to enable mobile auth.')
+ return false
+ }
+
+ const currentSession = sessionRef.current
+ if (!currentSession?.access_token) {
+ const { data, error } = await client.auth.getSession()
+ if (error) {
+ setMessage(error.message)
+ setState('error')
+ return false
+ }
+ sessionRef.current = data.session
+ setSession(data.session)
+ setUser(data.session?.user ?? null)
+ setState(data.session ? 'signed-in' : 'signed-out')
+ return Boolean(data.session?.access_token)
+ }
+
+ const expiresAtMs = currentSession.expires_at ? currentSession.expires_at * 1000 : 0
+ if (!expiresAtMs || expiresAtMs - Date.now() > 60_000) {
+ return true
+ }
+
+ const { data, error } = await client.auth.refreshSession(currentSession)
+ if (error) {
+ sessionRef.current = null
+ setSession(null)
+ setUser(null)
+ setState('signed-out')
+ setMessage(error.message)
+ return false
+ }
+
+ sessionRef.current = data.session
+ setSession(data.session)
+ setUser(data.session?.user ?? null)
+ setState(data.session ? 'signed-in' : 'signed-out')
+ setMessage(null)
+ return Boolean(data.session?.access_token)
}, [client])
- const getAuthHeaders = useCallback((): HeadersInit => (
- session?.access_token ? { Authorization: `Bearer ${session.access_token}` } : {}
- ), [session])
+ const getAuthHeaders = useCallback(async (): Promise => {
+ await refreshSession()
+ return sessionRef.current?.access_token ? { Authorization: `Bearer ${sessionRef.current.access_token}` } : {}
+ }, [refreshSession])
return {
getAuthHeaders,
message,
+ refreshSession,
session,
signOut,
state,
diff --git a/apps/mobile/src/mobilePreferences.ts b/apps/mobile/src/mobilePreferences.ts
index f00b268..8e17470 100644
--- a/apps/mobile/src/mobilePreferences.ts
+++ b/apps/mobile/src/mobilePreferences.ts
@@ -1,4 +1,4 @@
-import { createMeteorVoiceApiClient } from '@meteorvoice/api-client'
+import { createMeteorVoiceApiClient, type PreferencesResponse } from '@meteorvoice/api-client'
import type { VoiceProfile } from '@meteorvoice/shared'
export type XunfeiVoice = {
@@ -13,7 +13,8 @@ export type XunfeiVoice = {
type PrefInput = {
apiBaseUrl: string
- getAuthHeaders: () => HeadersInit
+ getAuthHeaders: () => HeadersInit | Promise
+ onUnauthorized?: () => void | Promise
ttsProvider?: string
ttsSpeed?: number
defaultScenarioKey?: string
@@ -25,16 +26,16 @@ type PrefInput = {
// 内存中记录同步失败的 key,下次成功 sync 时重试
const pendingSyncKeys = new Set()
-async function hasAuth(getAuthHeaders: () => HeadersInit) {
+async function hasAuth(getAuthHeaders: () => HeadersInit | Promise) {
const headers = await getAuthHeaders()
return !!(headers as Record).Authorization
}
-export async function syncMobilePreferences(input: PrefInput) {
- if (!input.apiBaseUrl) return
+export async function syncMobilePreferences(input: PrefInput): Promise {
+ if (!input.apiBaseUrl) return null
const authed = await hasAuth(input.getAuthHeaders)
- if (!authed) return
+ if (!authed) return null
const body: Record = {}
if (input.ttsProvider !== undefined) body.tts_provider = input.ttsProvider
@@ -44,24 +45,31 @@ export async function syncMobilePreferences(input: PrefInput) {
if (input.selectedVoiceProfileId !== undefined) body.selected_voice_profile_id = input.selectedVoiceProfileId
if (input.uiTheme !== undefined) body.ui_theme = input.uiTheme
- if (Object.keys(body).length === 0) return
+ if (Object.keys(body).length === 0) return null
try {
- const api = createMeteorVoiceApiClient({ baseUrl: input.apiBaseUrl, headers: input.getAuthHeaders })
- await api.updatePreferences(body)
+ const api = createMeteorVoiceApiClient({
+ baseUrl: input.apiBaseUrl,
+ headers: input.getAuthHeaders,
+ onUnauthorized: input.onUnauthorized,
+ })
+ const preferences = await api.updatePreferences(body)
// 成功后清除 pending
pendingSyncKeys.clear()
+ return preferences
} catch {
// 静默失败,记录待同步项(应用重启后下次 API 拉取会自动覆盖)
for (const key of Object.keys(body)) {
pendingSyncKeys.add(key)
}
+ return null
}
}
export async function pullMobilePreferences(
apiBaseUrl: string,
- getAuthHeaders: () => HeadersInit,
+ getAuthHeaders: () => HeadersInit | Promise,
+ onUnauthorized?: () => void | Promise,
): Promise<{
ttsProvider: string
ttsSpeed: number
@@ -82,7 +90,7 @@ export async function pullMobilePreferences(
if (!authed) return null
try {
- const api = createMeteorVoiceApiClient({ baseUrl: apiBaseUrl, headers: getAuthHeaders })
+ const api = createMeteorVoiceApiClient({ baseUrl: apiBaseUrl, headers: getAuthHeaders, onUnauthorized })
const raw = await api.getPreferences()
pendingSyncKeys.clear()
return {
diff --git a/apps/mobile/src/nativeAudio.ts b/apps/mobile/src/nativeAudio.ts
index e0092cd..b28da05 100644
--- a/apps/mobile/src/nativeAudio.ts
+++ b/apps/mobile/src/nativeAudio.ts
@@ -218,6 +218,18 @@ export function useNativeSessionAudio(audioUrl: string | null, playbackRateValue
})
}, [applyPlaybackRate, audioUrl, configurePlayback, player, recorder, recorderState.isRecording, runExclusive])
+ const stopPlayback = useCallback(() => {
+ try {
+ player.pause()
+ player.seekTo(0)
+ if (phase === 'playing') setPhase('idle')
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Coach voice failed to stop'
+ setErrorMessage(message)
+ setPhase('error')
+ }
+ }, [phase, player])
+
// 中断后恢复:清除中断标记,允许继续操作
const resumeAfterInterruption = useCallback(async () => {
if (!interrupted) return false
@@ -310,6 +322,7 @@ export function useNativeSessionAudio(audioUrl: string | null, playbackRateValue
playReply,
resumeAfterInterruption,
startRecording,
+ stopPlayback,
stopRecording,
}), [
errorMessage,
@@ -324,6 +337,7 @@ export function useNativeSessionAudio(audioUrl: string | null, playbackRateValue
resumeAfterInterruption,
recorderState.durationMillis,
startRecording,
+ stopPlayback,
stopRecording,
])
}
diff --git a/apps/mobile/src/screens/HistoryScreen.tsx b/apps/mobile/src/screens/HistoryScreen.tsx
index 7ea2458..c4b39d2 100644
--- a/apps/mobile/src/screens/HistoryScreen.tsx
+++ b/apps/mobile/src/screens/HistoryScreen.tsx
@@ -32,12 +32,6 @@ export function HistoryScreen({ tr, locale, sessions, loading, error, selectedHi
const { C } = useTheme()
const [expandedId, setExpandedId] = useState(null)
const [filterScenario, setFilterScenario] = useState(null)
- const [localSessions, setLocalSessions] = useState(sessions)
-
- // 同步外部 sessions 变化
- if (sessions !== localSessions && sessions.length !== localSessions.length) {
- setLocalSessions(sessions)
- }
function toggle(id: string | number) {
if (expandedId === id) {
@@ -49,13 +43,12 @@ export function HistoryScreen({ tr, locale, sessions, loading, error, selectedHi
}
function handleDelete(id: string) {
- setLocalSessions(prev => prev.map(s => s.id === id ? { ...s, status: 'deleted' } : s))
onDelete(id)
}
const filtered = filterScenario
- ? localSessions.filter(s => s.scenario_key === filterScenario || s.scenario === filterScenario)
- : localSessions
+ ? sessions.filter(s => s.scenario_key === filterScenario || s.scenario === filterScenario)
+ : sessions
const styles = useMemo(() => StyleSheet.create({
@@ -227,4 +220,3 @@ export function HistoryScreen({ tr, locale, sessions, loading, error, selectedHi
)
}
-
diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx
index a4c9320..5ffe5c1 100644
--- a/apps/mobile/src/screens/HomeScreen.tsx
+++ b/apps/mobile/src/screens/HomeScreen.tsx
@@ -12,18 +12,19 @@ interface Props {
scenarios: Scenario[]
selectedScenarioKey: string
isSessionActive: boolean
- onSelectScenario: (key: string) => void
+ scenarioSwitching: boolean
+ onSelectScenario: (key: string) => void | Promise
onGoToSession: () => void
}
export function HomeScreen({
tr, locale, scenarios,
- selectedScenarioKey, isSessionActive,
+ selectedScenarioKey, isSessionActive, scenarioSwitching,
onSelectScenario, onGoToSession,
}: Props) {
const { C } = useTheme()
- function handleScenario(key: string) {
- onSelectScenario(key)
+ async function handleScenario(key: string) {
+ await onSelectScenario(key)
onGoToSession()
}
@@ -40,6 +41,7 @@ export function HomeScreen({
borderWidth: 1, borderColor: C.border, padding: 14, gap: 6,
},
cardActive: { borderColor: C.accent, backgroundColor: 'rgba(49,95,72,0.2)' },
+ cardDisabled: { opacity: 0.5 },
cardIcon: { fontSize: 22 },
cardName: { color: C.textPrimary, fontSize: 14, fontWeight: '700' },
cardNameActive: { color: C.cream },
@@ -75,8 +77,9 @@ export function HomeScreen({
const active = item.key === selectedScenarioKey
return (
handleScenario(item.key)}
+ style={[styles.card, active && styles.cardActive, scenarioSwitching && styles.cardDisabled]}
+ disabled={scenarioSwitching}
+ onPress={() => { void handleScenario(item.key) }}
>
{item.icon}
@@ -99,4 +102,3 @@ export function HomeScreen({
)
}
-
diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx
index 8be44a1..d64bfff 100644
--- a/apps/mobile/src/screens/SettingsScreen.tsx
+++ b/apps/mobile/src/screens/SettingsScreen.tsx
@@ -11,23 +11,30 @@ interface Props {
locale: Locale
ttsProvider: string
availableProviders: string[]
+ sessionSttProvider: 'native' | 'xunfei'
+ availableSessionSttProviders: Array<'native' | 'xunfei'>
ttsSpeed: number
ttsVoiceId: string | null
voiceProfiles: VoiceProfile[]
selectedVoiceProfileId: string | null
xunfeiVoices: XunfeiVoice[]
settingsLoading: boolean
+ authSubmitting: boolean
settingsMessage: string | null
auth: MobileAuthState
email: string
password: string
authMode: 'sign-in' | 'sign-up'
apiBaseUrl: string
+ apiBaseUrlSource: 'default' | 'user'
+ defaultApiBaseUrl: string
appVersion: string
voiceMetricsText: string
+ asrEvaluationText: string
onSetLocale: (l: string) => void
onSetTheme: (k: ThemeKey) => void
onSaveProvider: (p: string) => void
+ onSetSessionSttProvider: (p: 'native' | 'xunfei') => void
onAdjustSpeed: (delta: number) => void
onSavePracticePreferences: () => void
onLoadPreferences: () => void
@@ -38,18 +45,21 @@ interface Props {
onSubmitAuth: () => void
onSignOut: () => void
onSetApiBaseUrl: (v: string) => void
+ onResetApiBaseUrl: () => void
onClearVoiceMetrics: () => void
onShareVoiceMetrics: () => void
+ onShareASREvaluation: () => void
}
export function SettingsScreen({
- tr, locale, ttsProvider, availableProviders, ttsSpeed,
+ tr, locale, ttsProvider, availableProviders, sessionSttProvider, availableSessionSttProviders, ttsSpeed,
ttsVoiceId, voiceProfiles, selectedVoiceProfileId, xunfeiVoices,
- settingsLoading, settingsMessage,
- auth, email, password, authMode, apiBaseUrl, appVersion, voiceMetricsText,
- onSetLocale, onSetTheme, onSaveProvider, onAdjustSpeed, onSavePracticePreferences,
+ settingsLoading, authSubmitting, settingsMessage,
+ auth, email, password, authMode, apiBaseUrl, apiBaseUrlSource, defaultApiBaseUrl, appVersion, voiceMetricsText, asrEvaluationText,
+ onSetLocale, onSetTheme, onSaveProvider, onSetSessionSttProvider, onAdjustSpeed, onSavePracticePreferences,
onLoadPreferences, onSelectVoiceProfile,
- onSetEmail, onSetPassword, onSetAuthMode, onSubmitAuth, onSignOut, onSetApiBaseUrl, onClearVoiceMetrics, onShareVoiceMetrics,
+ onSetEmail, onSetPassword, onSetAuthMode, onSubmitAuth, onSignOut, onSetApiBaseUrl,
+ onResetApiBaseUrl, onClearVoiceMetrics, onShareVoiceMetrics, onShareASREvaluation,
}: Props) {
const { C, themeKey } = useTheme()
const speedFill = Math.max(0, Math.min(1, (ttsSpeed - 0.7) / 0.6))
@@ -167,7 +177,12 @@ export function SettingsScreen({
{tr('settings.language')}
{(['en', 'zh'] as const).map(l => (
- onSetLocale(l)} style={[styles.chip, locale === l && styles.chipActive]}>
+ onSetLocale(l)}
+ disabled={settingsLoading}
+ style={[styles.chip, locale === l && styles.chipActive, settingsLoading && styles.chipDisabled]}
+ >
{l === 'en' ? tr('settings.language_en') : tr('settings.language_zh')}
))}
@@ -198,7 +213,12 @@ export function SettingsScreen({
{availableProviders.map(p => (
- onSaveProvider(p)} style={[styles.chip, ttsProvider === p && styles.chipActive]}>
+ onSaveProvider(p)}
+ disabled={settingsLoading}
+ style={[styles.chip, ttsProvider === p && styles.chipActive, settingsLoading && styles.chipDisabled]}
+ >
{tr(`settings.tts_provider_${p}`) !== `settings.tts_provider_${p}` ? tr(`settings.tts_provider_${p}`) : p}
@@ -208,6 +228,26 @@ export function SettingsScreen({
{settingsMessage && {settingsMessage}}
+ {/* Session STT Provider */}
+
+ {tr('settings.session_stt_provider')}
+
+ {availableSessionSttProviders.map(provider => (
+ onSetSessionSttProvider(provider)}
+ disabled={settingsLoading}
+ style={[styles.chip, sessionSttProvider === provider && styles.chipActive, settingsLoading && styles.chipDisabled]}
+ >
+
+ {tr(`settings.session_stt_provider_${provider}`)}
+
+
+ ))}
+
+ {tr('settings.session_stt_provider_hint')}
+
+
{/* Coach Voice */}
{tr('settings.voice_profile_current')}
@@ -237,6 +277,7 @@ export function SettingsScreen({
!unavailable && onSelectVoiceProfile(profile)}
+ disabled={settingsLoading || unavailable}
style={[styles.voiceCatalogChip, active && styles.chipActive, unavailable && styles.chipDisabled]}
>
{voiceProfileName(profile)}
@@ -260,25 +301,32 @@ export function SettingsScreen({
{tr('settings.tts_speed')}
- onAdjustSpeed(-0.1)} style={styles.speedBtn}>
+ onAdjustSpeed(-0.1)} disabled={settingsLoading} style={[styles.speedBtn, settingsLoading && styles.chipDisabled]}>
−
- onAdjustSpeed(0.1)} style={styles.speedBtn}>
+ onAdjustSpeed(0.1)} disabled={settingsLoading} style={[styles.speedBtn, settingsLoading && styles.chipDisabled]}>
+
{ttsSpeed.toFixed(1)}×
-
+
{tr('settings.save')}
{/* API URL */}
- {tr('settings.api_url')}
+
+ {tr('settings.api_url')}
+ {apiBaseUrlSource === 'user' && (
+
+ {tr('settings.api_url_reset')}
+
+ )}
+
+
+ {apiBaseUrlSource === 'user'
+ ? tr('settings.api_url_source_user')
+ : `${tr('settings.api_url_source_default')}: ${defaultApiBaseUrl}`}
+
{/* Diagnostics */}
@@ -297,7 +350,10 @@ export function SettingsScreen({
Voice diagnostics
- Share
+ Logs
+
+
+ ASR
Clear
@@ -307,7 +363,7 @@ export function SettingsScreen({
- {voiceMetricsText || 'No voice metrics yet.'}
+ {voiceMetricsText || asrEvaluationText || 'No voice metrics yet.'}
@@ -324,7 +380,12 @@ export function SettingsScreen({
) : (
{(['sign-in', 'sign-up'] as const).map(m => (
- onSetAuthMode(m)} style={[styles.modeBtn, authMode === m && styles.modeBtnActive]}>
+ onSetAuthMode(m)}
+ disabled={authSubmitting || auth.state === 'loading'}
+ style={[styles.modeBtn, authMode === m && styles.modeBtnActive, (authSubmitting || auth.state === 'loading') && styles.disabled]}
+ >
{m === 'sign-in' ? tr('login.signin') : tr('login.signup')}
@@ -348,15 +409,21 @@ export function SettingsScreen({
-
+
- {auth.state === 'loading' ? tr('login.loading') : authMode === 'sign-in' ? tr('login.signin') : tr('login.signup')}
+ {authSubmitting || auth.state === 'loading' ? tr('login.loading') : authMode === 'sign-in' ? tr('login.signin') : tr('login.signup')}
diff --git a/apps/mobile/src/voicePcmCapture.ts b/apps/mobile/src/voicePcmCapture.ts
new file mode 100644
index 0000000..3c36b2f
--- /dev/null
+++ b/apps/mobile/src/voicePcmCapture.ts
@@ -0,0 +1,99 @@
+import { requireOptionalNativeModule, type EventSubscription } from 'expo-modules-core'
+import { Platform } from 'react-native'
+
+export type PcmCaptureOptions = {
+ sampleRate?: number
+ frameDurationMs?: number
+}
+
+export type PcmCaptureStatus = {
+ isCapturing: boolean
+ sampleRate: number
+ channels: number
+ bitDepth: number
+ frameDurationMs: number
+ frameSizeBytes: number
+ frameCount: number
+ totalBytes: number
+ elapsedMs: number
+ reason?: string
+}
+
+export type PcmCaptureFrameEvent = {
+ sequence: number
+ timestampMs: number
+ elapsedMs: number
+ audioBase64: string
+ byteCount: number
+ sampleRate: number
+ channels: number
+ bitDepth: number
+ durationMs: number
+}
+
+export type PcmCaptureStateEvent = PcmCaptureStatus & {
+ state: 'started' | 'stopped' | 'error' | 'restarting' | 'restarted' | 'interrupted' | 'route_change_ignored'
+ message?: string
+}
+
+type VoicePcmCaptureNativeModule = {
+ start: (options: PcmCaptureOptions) => Promise
+ stop: (reason?: string) => Promise
+ getStatus: () => Promise
+ addListener: (
+ eventName: 'onPcmCaptureFrame' | 'onPcmCaptureState',
+ listener: (event: PcmCaptureFrameEvent | PcmCaptureStateEvent) => void,
+ ) => EventSubscription
+}
+
+function getNativeModule() {
+ if (Platform.OS !== 'ios') return null
+ return requireOptionalNativeModule('VoicePcmCapture')
+}
+
+export function isPcmCaptureAvailable() {
+ return Boolean(getNativeModule())
+}
+
+export async function startPcmCapture(options: PcmCaptureOptions = {}) {
+ const nativeModule = getNativeModule()
+ if (!nativeModule) {
+ throw new Error('VoicePcmCapture native module is unavailable.')
+ }
+ return nativeModule.start({
+ sampleRate: options.sampleRate ?? 16000,
+ frameDurationMs: options.frameDurationMs ?? 40,
+ })
+}
+
+export async function stopPcmCapture(reason = 'manual') {
+ const nativeModule = getNativeModule()
+ if (!nativeModule) {
+ throw new Error('VoicePcmCapture native module is unavailable.')
+ }
+ return nativeModule.stop(reason)
+}
+
+export async function getPcmCaptureStatus() {
+ const nativeModule = getNativeModule()
+ if (!nativeModule) {
+ throw new Error('VoicePcmCapture native module is unavailable.')
+ }
+ return nativeModule.getStatus()
+}
+
+export function addPcmFrameListener(listener: (event: PcmCaptureFrameEvent) => void) {
+ const nativeModule = getNativeModule()
+ if (!nativeModule) return { remove() {} } as EventSubscription
+ return nativeModule.addListener('onPcmCaptureFrame', event => {
+ listener(event as PcmCaptureFrameEvent)
+ })
+}
+
+export function addPcmStateListener(listener: (event: PcmCaptureStateEvent) => void) {
+ const nativeModule = getNativeModule()
+ if (!nativeModule) return { remove() {} } as EventSubscription
+ return nativeModule.addListener('onPcmCaptureState', event => {
+ listener(event as PcmCaptureStateEvent)
+ })
+}
diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example
index 238c8fc..9f2ce0c 100644
--- a/apps/web/.env.local.example
+++ b/apps/web/.env.local.example
@@ -7,14 +7,15 @@ SUPABASE_SERVICE_ROLE_KEY=
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
-# Optional: Production STT/TTS providers
-# STT_PROVIDER=mock|browser|whisper
+# Optional: Production ASR/TTS providers
+ASR_PROVIDER=native
# TTS_PROVIDER=mock|xunfei|volcengine|tencent|azure
-# Xunfei TTS (recommended first provider in China)
+# Xunfei ASR/TTS (recommended first provider in China)
XUNFEI_APP_ID=
XUNFEI_API_KEY=
XUNFEI_API_SECRET=
+XUNFEI_ASR_PRODUCT=zh_iat
# Required when TTS_PROVIDER=xunfei. Use a V3-compatible vcn from the Xunfei console.
XUNFEI_TTS_VOICE=
# Specific Xunfei coach voices are selected in Settings from the app voice catalog.
diff --git a/apps/web/app/api/asr/providers/route.ts b/apps/web/app/api/asr/providers/route.ts
new file mode 100644
index 0000000..e4316c2
--- /dev/null
+++ b/apps/web/app/api/asr/providers/route.ts
@@ -0,0 +1,15 @@
+import { jsonApiResult, jsonServerError } from '@/lib/server/http'
+import { getASRProviders, getDefaultASRProvider } from '@/lib/server/asr'
+
+export const runtime = 'nodejs'
+
+export async function GET() {
+ try {
+ return jsonApiResult({
+ providers: getASRProviders(),
+ default_provider: getDefaultASRProvider(),
+ })
+ } catch (error) {
+ return jsonServerError(error, 'Failed to load ASR providers')
+ }
+}
diff --git a/apps/web/app/api/asr/session/route.ts b/apps/web/app/api/asr/session/route.ts
new file mode 100644
index 0000000..099d0de
--- /dev/null
+++ b/apps/web/app/api/asr/session/route.ts
@@ -0,0 +1,18 @@
+import { guardApiRequest, jsonApiResult, jsonServerError, requireApiUser } from '@/lib/server/http'
+import { createASRSessionFromRequest } from '@/lib/server/asr'
+import type { ASRSessionBootstrapRequest } from '@meteorvoice/shared'
+
+export const runtime = 'nodejs'
+
+export async function POST(request: Request) {
+ try {
+ const guard = guardApiRequest(request, { name: 'asr_session', windowMs: 60_000, maxRequests: 30, requireClientHeader: true })
+ if (guard) return jsonApiResult(guard)
+ const auth = await requireApiUser()
+ if (auth) return jsonApiResult(auth)
+ const body = await request.json() as ASRSessionBootstrapRequest
+ return jsonApiResult(await createASRSessionFromRequest(body))
+ } catch (error) {
+ return jsonServerError(error, 'Failed to create ASR session')
+ }
+}
diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts
index 644f4d7..bcf1bca 100644
--- a/apps/web/app/api/chat/route.ts
+++ b/apps/web/app/api/chat/route.ts
@@ -1,9 +1,13 @@
import type { ConversationMessage, ConversationContext } from '@/lib/providers/types'
import { generateCoachReply } from '@/lib/server/chat'
-import { jsonApiResult, jsonServerError } from '@/lib/server/http'
+import { guardApiRequest, jsonApiResult, jsonServerError, requireApiUser } from '@/lib/server/http'
export async function POST(request: Request) {
try {
+ const guard = guardApiRequest(request, { name: 'chat', windowMs: 60_000, maxRequests: 30, requireClientHeader: true })
+ if (guard) return jsonApiResult(guard)
+ const auth = await requireApiUser()
+ if (auth) return jsonApiResult(auth)
const body = await request.json() as {
messages: ConversationMessage[]
context: ConversationContext
diff --git a/apps/web/app/api/preferences/route.ts b/apps/web/app/api/preferences/route.ts
index b395398..a824ae7 100644
--- a/apps/web/app/api/preferences/route.ts
+++ b/apps/web/app/api/preferences/route.ts
@@ -1,8 +1,12 @@
-import { jsonApiResult, jsonServerError } from '@/lib/server/http'
+import { guardApiRequest, jsonApiResult, jsonServerError, requireApiUser } from '@/lib/server/http'
import { getPreferences, setPreferences } from '@/lib/server/preferences'
-export async function GET() {
+export async function GET(request: Request) {
try {
+ const guard = guardApiRequest(request, { name: 'preferences_get', windowMs: 60_000, maxRequests: 30, requireClientHeader: true })
+ if (guard) return jsonApiResult(guard)
+ const auth = await requireApiUser()
+ if (auth) return jsonApiResult(auth)
return jsonApiResult(await getPreferences())
} catch (error) {
return jsonServerError(error, 'Failed to load preferences')
@@ -11,6 +15,10 @@ export async function GET() {
export async function PATCH(request: Request) {
try {
+ const guard = guardApiRequest(request, { name: 'preferences_patch', windowMs: 60_000, maxRequests: 30, requireClientHeader: true })
+ if (guard) return jsonApiResult(guard)
+ const auth = await requireApiUser()
+ if (auth) return jsonApiResult(auth)
const body = await request.json() as {
tts_provider?: string
locale?: string
diff --git a/apps/web/app/api/semantic-endpoint/route.ts b/apps/web/app/api/semantic-endpoint/route.ts
index 1e9f528..169f415 100644
--- a/apps/web/app/api/semantic-endpoint/route.ts
+++ b/apps/web/app/api/semantic-endpoint/route.ts
@@ -1,4 +1,4 @@
-import { jsonApiResult, jsonServerError } from '@/lib/server/http'
+import { guardApiRequest, jsonApiResult, jsonServerError, requireApiUser } from '@/lib/server/http'
import { createSemanticEndpointCheck } from '@/lib/server/semantic-endpoint'
const MAX_TRANSCRIPT_LENGTH = 2000
@@ -28,6 +28,10 @@ async function getCheck() {
export async function POST(request: Request) {
try {
+ const guard = guardApiRequest(request, { name: 'semantic_endpoint', windowMs: 60_000, maxRequests: 120, requireClientHeader: true })
+ if (guard) return jsonApiResult(guard)
+ const auth = await requireApiUser()
+ if (auth) return jsonApiResult(auth)
const body = await request.json() as unknown
if (!body || typeof body !== 'object') {
return jsonApiResult({ error: 'Invalid request body', status: 400 })
diff --git a/apps/web/app/api/tts/route.ts b/apps/web/app/api/tts/route.ts
index f9ff17f..39d1630 100644
--- a/apps/web/app/api/tts/route.ts
+++ b/apps/web/app/api/tts/route.ts
@@ -1,10 +1,14 @@
-import { jsonApiResult, jsonServerError } from '@/lib/server/http'
+import { guardApiRequest, jsonApiResult, jsonServerError, requireApiUser } from '@/lib/server/http'
import { synthesizeSpeechFromRequest } from '@/lib/server/tts'
export const runtime = 'nodejs'
export async function POST(request: Request) {
try {
+ const guard = guardApiRequest(request, { name: 'tts', windowMs: 60_000, maxRequests: 60, requireClientHeader: true })
+ if (guard) return jsonApiResult(guard)
+ const auth = await requireApiUser()
+ if (auth) return jsonApiResult(auth)
const body = await request.json() as {
text?: string
accent?: string
diff --git a/apps/web/app/history/page.tsx b/apps/web/app/history/page.tsx
index 61dc667..3c4dee1 100644
--- a/apps/web/app/history/page.tsx
+++ b/apps/web/app/history/page.tsx
@@ -5,6 +5,8 @@ import { useLocale, useT } from '@/components/LanguageProvider'
import { Card, CardContent } from '@/components/ui/card'
import { scenarios, findAccentByKeyOrName, findScenarioByKeyOrName, getAccentLabel, getScenarioLabel } from '@/lib/scenarios'
import { flushPendingPreferences } from '@/lib/tts-speed'
+import { formatApiRequestError, readApiJsonResponse } from '@meteorvoice/api-client'
+import { displayErrorFeedback, hideAppFeedback, runAppOperationGroup } from '@meteorvoice/shared'
interface HistorySession {
id: string
@@ -34,6 +36,8 @@ interface TurnData {
}
const PAGE_SIZE = 20
+const historyFeedbackSource = 'web_history'
+const historyErrorFeedbackSource = 'web_history_error'
export default function HistoryPage() {
const { locale } = useLocale()
@@ -50,6 +54,7 @@ export default function HistoryPage() {
const [turnsLoading, setTurnsLoading] = useState(false)
const [turnsError, setTurnsError] = useState(null)
const [deletingId, setDeletingId] = useState(null)
+ const historyLoadingMessage = t('history.loading')
function statusLabel(status: string) {
const key = `history.status.${status}`
@@ -78,15 +83,27 @@ export default function HistoryPage() {
if (scenario) params.set('scenario', scenario)
const res = await fetch(`/api/history?${params.toString()}`)
- if (!res.ok) throw new Error('Failed to load')
- return res.json() as Promise<{ sessions: HistorySession[]; hasMore: boolean }>
+ return readApiJsonResponse<{ sessions: HistorySession[]; hasMore: boolean }>(res, 'History request failed')
}, [])
// 首次加载
useEffect(() => {
void flushPendingPreferences()
- loadSessions(0, filterScenario)
- .then(data => {
+ async function loadInitialSessions() {
+ setLoading(true)
+ const results = await runAppOperationGroup({
+ source: historyFeedbackSource,
+ feedback: {
+ message: historyLoadingMessage,
+ variant: 'hud',
+ blocksInteraction: true,
+ },
+ tasks: {
+ sessions: () => loadSessions(0, filterScenario),
+ },
+ })
+ if (results.sessions.status === 'fulfilled') {
+ const data = results.sessions.value
if (data.sessions && data.sessions.length > 0) {
setSessions(data.sessions)
setHasMore(data.hasMore)
@@ -94,26 +111,46 @@ export default function HistoryPage() {
} else {
setSource('none')
}
- })
- .catch(err => {
- setError(err instanceof Error ? err.message : t('history.load_error'))
- })
- .finally(() => setLoading(false))
- }, [filterScenario, loadSessions, t])
+ } else {
+ const requestError = formatApiRequestError(results.sessions.reason, {
+ context: 'web_history_list',
+ presentation: 'inline',
+ })
+ setError(requestError.displayMessage)
+ }
+ setLoading(false)
+ }
+ void loadInitialSessions()
+ return () => hideAppFeedback(historyFeedbackSource)
+ }, [filterScenario, historyLoadingMessage, loadSessions])
// 加载更多
async function handleLoadMore() {
if (loadingMore || !hasMore) return
setLoadingMore(true)
- try {
- const data = await loadSessions(sessions.length, filterScenario)
+ const results = await runAppOperationGroup({
+ source: historyFeedbackSource,
+ feedback: {
+ message: historyLoadingMessage,
+ variant: 'hud',
+ blocksInteraction: true,
+ },
+ tasks: {
+ sessions: () => loadSessions(sessions.length, filterScenario),
+ },
+ })
+ if (results.sessions.status === 'fulfilled') {
+ const data = results.sessions.value
setSessions(prev => [...prev, ...(data.sessions ?? [])])
setHasMore(data.hasMore)
- } catch {
- // 静默失败
- } finally {
- setLoadingMore(false)
+ } else {
+ const requestError = formatApiRequestError(results.sessions.reason, {
+ context: 'web_history_load_more',
+ presentation: 'banner',
+ })
+ displayErrorFeedback(requestError, historyErrorFeedbackSource)
}
+ setLoadingMore(false)
}
// 展开/收起 session turns
@@ -127,32 +164,61 @@ export default function HistoryPage() {
setTurns([])
setTurnsError(null)
setTurnsLoading(true)
- try {
- const res = await fetch(`/api/sessions/${encodeURIComponent(id)}/turns`)
- if (!res.ok) throw new Error('Failed to load turns')
- const data = await res.json() as { turns: TurnData[] }
- setTurns(data.turns ?? [])
- } catch {
- setTurnsError(t('history.load_error'))
- } finally {
- setTurnsLoading(false)
+ const results = await runAppOperationGroup({
+ source: historyFeedbackSource,
+ feedback: {
+ message: historyLoadingMessage,
+ variant: 'hud',
+ blocksInteraction: true,
+ },
+ tasks: {
+ turns: async () => {
+ const res = await fetch(`/api/sessions/${encodeURIComponent(id)}/turns`)
+ return readApiJsonResponse<{ turns: TurnData[] }>(res, 'History turns request failed')
+ },
+ },
+ })
+ if (results.turns.status === 'fulfilled') {
+ setTurns(results.turns.value.turns ?? [])
+ } else {
+ const requestError = formatApiRequestError(results.turns.reason, {
+ context: 'web_history_turns',
+ presentation: 'inline',
+ })
+ setTurnsError(requestError.displayMessage)
}
+ setTurnsLoading(false)
}
// 软删除
async function handleDelete(id: string) {
if (!confirm(t('history.delete_confirm'))) return
setDeletingId(id)
- try {
- const res = await fetch(`/api/session?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
- if (res.ok) {
- setSessions(prev => prev.map(s => s.id === id ? { ...s, status: 'deleted' } : s))
- }
- } catch {
- // 静默失败
- } finally {
- setDeletingId(null)
+ const results = await runAppOperationGroup({
+ source: historyFeedbackSource,
+ feedback: {
+ message: historyLoadingMessage,
+ variant: 'hud',
+ blocksInteraction: true,
+ },
+ tasks: {
+ deleted: async () => {
+ const res = await fetch(`/api/session?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
+ if (!res.ok) throw new Error(`Delete failed: ${res.status}`)
+ return true
+ },
+ },
+ })
+ if (results.deleted.status === 'fulfilled') {
+ setSessions(prev => prev.map(s => s.id === id ? { ...s, status: 'deleted' } : s))
+ } else {
+ const requestError = formatApiRequestError(results.deleted.reason, {
+ context: 'web_history_delete',
+ presentation: 'banner',
+ })
+ displayErrorFeedback(requestError, historyErrorFeedbackSource)
}
+ setDeletingId(null)
}
return (
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index c7aa594..641718d 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -5,6 +5,7 @@ import ThemeProvider from '@/components/ThemeProvider'
import LanguageProvider from '@/components/LanguageProvider'
import VoiceSessionProvider from '@/components/VoiceSessionProvider'
import ActiveSessionBar from '@/components/ActiveSessionBar'
+import AppFeedbackPresenter from '@/components/AppFeedbackPresenter'
export const metadata: Metadata = {
title: 'MeteorVoice',
@@ -27,6 +28,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
+
diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx
index 882a675..4c9ca43 100644
--- a/apps/web/app/settings/page.tsx
+++ b/apps/web/app/settings/page.tsx
@@ -2,11 +2,12 @@
import { useTheme, themes } from '@/components/ThemeProvider'
import { useLocale, useT } from '@/components/LanguageProvider'
-import { persistPreference, persistTTSSpeedPreference, readTTSSpeedPreference, ttsSpeedOptions, writeTTSSpeedPreference, type TTSSpeed } from '@/lib/tts-speed'
+import { readTTSSpeedPreference, ttsSpeedOptions, writeTTSSpeedPreference, type TTSSpeed } from '@/lib/tts-speed'
import { writeTTSVoiceIdPreference } from '@/lib/tts-voice'
-import type { Locale, VoiceProfile } from '@meteorvoice/shared'
+import { displayErrorFeedback, hideAppFeedback, runAppOperationGroup, type Locale, type VoiceProfile } from '@meteorvoice/shared'
+import { formatApiRequestError, readApiJsonResponse } from '@meteorvoice/api-client'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
const allTtsProviders = [
{ key: 'mock', labelKey: 'settings.tts_provider_mock' },
@@ -39,6 +40,19 @@ function getVoiceProfileName(profile: VoiceProfile, locale: Locale) {
return locale === 'zh' ? profile.displayNameZh ?? profile.displayName : profile.displayName
}
+type PreferencesPayload = {
+ tts_provider?: string
+ available_providers?: string[]
+ locale?: Locale
+ tts_speed?: number
+ tts_voice_id?: string | null
+ voice_profiles?: VoiceProfile[]
+ selected_voice_profile_id?: string | null
+}
+
+const settingsFeedbackSource = 'web_settings'
+const settingsErrorFeedbackSource = 'web_settings_error'
+
export default function SettingsPage() {
const { theme, setTheme } = useTheme()
const { locale, setLocale } = useLocale()
@@ -49,66 +63,150 @@ export default function SettingsPage() {
const [voiceProfiles, setVoiceProfiles] = useState([])
const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState(null)
const [availableProviders, setAvailableProviders] = useState(['mock'])
+ const [settingsError, setSettingsError] = useState(null)
+ const [settingsLoading, setSettingsLoading] = useState(false)
+ const settingsSyncingMessage = t('settings.syncing')
+
+ const applyPreferences = useCallback((data: PreferencesPayload) => {
+ setSettingsError(null)
+ if (data.locale) setLocale(data.locale)
+ if (data.tts_provider) setTtsProvider(data.tts_provider)
+ if (data.available_providers) setAvailableProviders(data.available_providers)
+ if (data.voice_profiles) setVoiceProfiles(data.voice_profiles)
+ if ('selected_voice_profile_id' in data) setSelectedVoiceProfileId(data.selected_voice_profile_id ?? null)
+ if ('tts_voice_id' in data) {
+ setTtsVoiceId(data.tts_voice_id ?? null)
+ writeTTSVoiceIdPreference(data.tts_voice_id ?? null)
+ }
+ if (typeof data.tts_speed === 'number') {
+ const serverSpeed = data.tts_speed
+ const nextSpeed = ttsSpeedOptions.reduce((best, option) =>
+ Math.abs(option - serverSpeed) < Math.abs(best - serverSpeed) ? option : best,
+ ttsSpeedOptions[2])
+ setTtsSpeed(nextSpeed)
+ writeTTSSpeedPreference(nextSpeed)
+ }
+ }, [setLocale])
+
+ const applyPreferenceUpdate = useCallback((data: PreferencesPayload, body: Record) => {
+ setSettingsError(null)
+ if ('locale' in body && data.locale) setLocale(data.locale)
+ if ('tts_provider' in body && data.tts_provider) setTtsProvider(data.tts_provider)
+ if ('tts_speed' in body && typeof data.tts_speed === 'number') {
+ const serverSpeed = data.tts_speed
+ const nextSpeed = ttsSpeedOptions.reduce((best, option) =>
+ Math.abs(option - serverSpeed) < Math.abs(best - serverSpeed) ? option : best,
+ ttsSpeedOptions[2])
+ setTtsSpeed(nextSpeed)
+ writeTTSSpeedPreference(nextSpeed)
+ }
+ if ('selected_voice_profile_id' in body || 'tts_provider' in body) {
+ if ('selected_voice_profile_id' in data) setSelectedVoiceProfileId(data.selected_voice_profile_id ?? null)
+ if ('tts_voice_id' in data) {
+ setTtsVoiceId(data.tts_voice_id ?? null)
+ writeTTSVoiceIdPreference(data.tts_voice_id ?? null)
+ }
+ const profile = voiceProfiles.find(item => item.id === data.selected_voice_profile_id)
+ if (profile?.provider) setTtsProvider(profile.provider)
+ }
+ }, [setLocale, voiceProfiles])
+
+ const fetchPreferences = useCallback(async () => {
+ const res = await fetch('/api/preferences', {
+ headers: { 'X-MeteorVoice-Client': 'meteorvoice-web' },
+ })
+ return readApiJsonResponse(res, 'Preferences request failed')
+ }, [])
useEffect(() => {
- async function loadTtsProvider() {
- try {
- const res = await fetch('/api/preferences')
- const data = await res.json() as {
- tts_provider?: string
- available_providers?: string[]
- tts_speed?: number
- tts_voice_id?: string | null
- voice_profiles?: VoiceProfile[]
- selected_voice_profile_id?: string | null
- }
- if (data.tts_provider) setTtsProvider(data.tts_provider)
- if (data.available_providers) setAvailableProviders(data.available_providers)
- if (data.voice_profiles) setVoiceProfiles(data.voice_profiles)
- if ('selected_voice_profile_id' in data) setSelectedVoiceProfileId(data.selected_voice_profile_id ?? null)
- if ('tts_voice_id' in data) {
- setTtsVoiceId(data.tts_voice_id ?? null)
- writeTTSVoiceIdPreference(data.tts_voice_id ?? null)
- }
- if (typeof data.tts_speed === 'number') {
- const serverSpeed = data.tts_speed
- const nextSpeed = ttsSpeedOptions.reduce((best, option) =>
- Math.abs(option - serverSpeed) < Math.abs(best - serverSpeed) ? option : best,
- ttsSpeedOptions[2])
- setTtsSpeed(nextSpeed)
- writeTTSSpeedPreference(nextSpeed)
- }
- } catch {}
+ async function loadInitialPreferences() {
+ const results = await runAppOperationGroup({
+ source: settingsFeedbackSource,
+ tasks: {
+ preferences: fetchPreferences,
+ },
+ })
+ if (results.preferences.status === 'fulfilled') {
+ applyPreferences(results.preferences.value)
+ return
+ }
+ const requestError = formatApiRequestError(results.preferences.reason, {
+ context: 'web_settings_preferences_load',
+ presentation: 'inline',
+ })
+ setSettingsError(requestError.displayMessage)
}
- void loadTtsProvider()
+ void loadInitialPreferences()
+ }, [applyPreferences, fetchPreferences])
+
+ useEffect(() => () => {
+ hideAppFeedback(settingsFeedbackSource)
}, [])
+ async function savePreferences(body: Record, context: string) {
+ if (settingsLoading) return
+ setSettingsLoading(true)
+ setSettingsError(null)
+ const results = await runAppOperationGroup({
+ source: settingsFeedbackSource,
+ feedback: {
+ message: settingsSyncingMessage,
+ variant: 'hud',
+ blocksInteraction: true,
+ },
+ tasks: {
+ preferences: async () => {
+ const res = await fetch('/api/preferences', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-web' },
+ body: JSON.stringify(body),
+ })
+ return readApiJsonResponse(res, 'Preferences update failed')
+ },
+ },
+ })
+ if (results.preferences.status === 'fulfilled') {
+ applyPreferenceUpdate(results.preferences.value, body)
+ setSettingsLoading(false)
+ return
+ }
+ try {
+ throw results.preferences.reason
+ } catch (error) {
+ const requestError = formatApiRequestError(error, {
+ context,
+ presentation: 'banner',
+ })
+ setSettingsError(requestError.displayMessage)
+ displayErrorFeedback(requestError, settingsErrorFeedbackSource)
+ } finally {
+ setSettingsLoading(false)
+ }
+ }
+
function handleTtsProviderChange(key: string) {
- setTtsProvider(key)
const nextProfile = voiceProfiles.find(profile => profile.provider === key && profile.status === 'active')
- if (nextProfile) {
- setSelectedVoiceProfileId(nextProfile.id)
- setTtsVoiceId(nextProfile.providerVoiceId)
- writeTTSVoiceIdPreference(nextProfile.providerVoiceId)
- }
- void persistPreference('tts_provider', key)
- if (nextProfile) void persistPreference('selected_voice_profile_id', nextProfile.id)
+ void savePreferences({
+ tts_provider: key,
+ ...(nextProfile ? { selected_voice_profile_id: nextProfile.id } : {}),
+ }, 'web_settings_tts_provider_save')
}
function handleTtsSpeedChange(index: number) {
const next = ttsSpeedOptions[index] ?? 1
- setTtsSpeed(next)
- void persistTTSSpeedPreference(next)
+ void savePreferences({ tts_speed: next }, 'web_settings_tts_speed_save')
}
function handleVoiceProfileChange(profile: VoiceProfile) {
if (profile.status !== 'active') return
- setSelectedVoiceProfileId(profile.id)
- setTtsProvider(profile.provider)
- setTtsVoiceId(profile.providerVoiceId)
- writeTTSVoiceIdPreference(profile.providerVoiceId)
- void persistPreference('selected_voice_profile_id', profile.id)
+ void savePreferences({ selected_voice_profile_id: profile.id }, 'web_settings_voice_profile_save')
+ }
+
+ function handleLocaleChange(nextLocale: Locale) {
+ if (nextLocale === locale) return
+ setLocale(nextLocale)
+ void savePreferences({ locale: nextLocale }, 'web_settings_locale_save')
}
const providerVoiceProfiles = voiceProfiles.filter(profile => profile.provider === ttsProvider)
@@ -124,6 +222,11 @@ export default function SettingsPage() {
{t('settings.subtitle')}
+ {settingsError && (
+
+ {settingsError}
+
+ )}
@@ -137,6 +240,7 @@ export default function SettingsPage() {
key={th.key}
type="button"
onClick={() => setTheme(th.key)}
+ disabled={settingsLoading}
className={`chip-action ${th.key === theme ? 'is-active' : ''}`}
>
{t(th.labelKey)}
@@ -160,7 +264,8 @@ export default function SettingsPage() {