From eb5ff8e6f0f672e196762c116443a12554ae7b93 Mon Sep 17 00:00:00 2001
From: ConnorQi
Date: Sun, 31 May 2026 18:04:34 +0800
Subject: [PATCH 01/37] Add shared ASR provider layer
---
apps/web/app/api/asr/providers/route.ts | 15 ++
apps/web/app/api/asr/session/route.ts | 14 ++
apps/web/lib/server/asr.ts | 97 ++++++++
docs/asr-provider-layer.md | 312 ++++++++++++++++++++++++
docs/index.md | 4 +
packages/api-client/src/client.ts | 14 ++
packages/api-client/src/types.ts | 13 +
packages/shared/package.json | 3 +-
packages/shared/src/asr.ts | 203 +++++++++++++++
packages/shared/src/index.ts | 1 +
tests/api-client.test.ts | 27 ++
tests/asr-provider.test.ts | 99 ++++++++
vitest.config.ts | 1 +
13 files changed, 802 insertions(+), 1 deletion(-)
create mode 100644 apps/web/app/api/asr/providers/route.ts
create mode 100644 apps/web/app/api/asr/session/route.ts
create mode 100644 apps/web/lib/server/asr.ts
create mode 100644 docs/asr-provider-layer.md
create mode 100644 packages/shared/src/asr.ts
create mode 100644 tests/asr-provider.test.ts
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..bb97d95
--- /dev/null
+++ b/apps/web/app/api/asr/session/route.ts
@@ -0,0 +1,14 @@
+import { jsonApiResult, jsonServerError } 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 body = await request.json() as ASRSessionBootstrapRequest
+ return jsonApiResult(createASRSessionFromRequest(body))
+ } catch (error) {
+ return jsonServerError(error, 'Failed to create ASR session')
+ }
+}
diff --git a/apps/web/lib/server/asr.ts b/apps/web/lib/server/asr.ts
new file mode 100644
index 0000000..eb9b890
--- /dev/null
+++ b/apps/web/lib/server/asr.ts
@@ -0,0 +1,97 @@
+import {
+ asrProviderKeys,
+ createASRProviderDescriptor,
+ normalizeASRProviderKey,
+ normalizeASRSessionConfig,
+ type ASRProviderDescriptor,
+ type ASRProviderKey,
+ type ASRSessionBootstrapRequest,
+ type ASRSessionBootstrapResponse,
+} from '@meteorvoice/shared'
+
+const serverBootstrapPendingMessage = 'ASR server bootstrap is not implemented for this provider yet'
+
+export function getASRProviders(): ASRProviderDescriptor[] {
+ return asrProviderKeys.map(key => {
+ const configured = isASRProviderConfigured(key)
+ const enabled = key === 'native' || configured
+ return createASRProviderDescriptor(key, {
+ configured,
+ enabled,
+ disabledReason: enabled ? undefined : missingConfigurationReason(key),
+ })
+ })
+}
+
+export function getDefaultASRProvider(): ASRProviderKey {
+ const configuredDefault = normalizeASRProviderKey(process.env.ASR_PROVIDER, 'native')
+ const provider = getASRProviders().find(item => item.key === configuredDefault)
+ return provider?.enabled ? provider.key : 'native'
+}
+
+export function createASRSessionFromRequest(input: ASRSessionBootstrapRequest) {
+ const provider = normalizeASRProviderKey(input.provider, getDefaultASRProvider())
+ const descriptor = getASRProviders().find(item => item.key === provider)
+
+ if (!descriptor?.enabled) {
+ return {
+ error: descriptor?.disabledReason ?? 'ASR provider is not configured',
+ status: 400 as const,
+ }
+ }
+
+ const config = normalizeASRSessionConfig({ ...input, provider })
+ const sessionId = config.sessionId ?? createASRSessionId(provider)
+
+ if (provider === 'native') {
+ return {
+ provider,
+ status: 'unsupported',
+ sessionId,
+ transport: 'native',
+ config: { ...config, sessionId },
+ } satisfies ASRSessionBootstrapResponse
+ }
+
+ return {
+ error: serverBootstrapPendingMessage,
+ status: 501 as const,
+ }
+}
+
+function isASRProviderConfigured(provider: ASRProviderKey) {
+ if (provider === 'native') return true
+ if (provider === 'xunfei') {
+ return Boolean(process.env.XUNFEI_ASR_APP_ID && process.env.XUNFEI_ASR_API_KEY && process.env.XUNFEI_ASR_API_SECRET)
+ }
+ if (provider === 'azure') {
+ return Boolean(process.env.AZURE_SPEECH_KEY && process.env.AZURE_SPEECH_REGION)
+ }
+ if (provider === 'tencent') {
+ return Boolean(process.env.TENCENT_SECRET_ID && process.env.TENCENT_SECRET_KEY)
+ }
+ if (provider === 'volcengine') {
+ return Boolean(process.env.VOLCENGINE_ASR_APP_ID && process.env.VOLCENGINE_ASR_ACCESS_TOKEN)
+ }
+ return false
+}
+
+function missingConfigurationReason(provider: ASRProviderKey) {
+ if (provider === 'native') return undefined
+ return `Missing ${requiredEnvNames(provider).join(', ')}`
+}
+
+function requiredEnvNames(provider: ASRProviderKey) {
+ if (provider === 'xunfei') return ['XUNFEI_ASR_APP_ID', 'XUNFEI_ASR_API_KEY', 'XUNFEI_ASR_API_SECRET']
+ if (provider === 'azure') return ['AZURE_SPEECH_KEY', 'AZURE_SPEECH_REGION']
+ if (provider === 'tencent') return ['TENCENT_SECRET_ID', 'TENCENT_SECRET_KEY']
+ if (provider === 'volcengine') return ['VOLCENGINE_ASR_APP_ID', 'VOLCENGINE_ASR_ACCESS_TOKEN']
+ return []
+}
+
+function createASRSessionId(provider: ASRProviderKey) {
+ const random = typeof crypto !== 'undefined' && 'randomUUID' in crypto
+ ? crypto.randomUUID()
+ : `${Date.now()}-${Math.random().toString(16).slice(2)}`
+ return `asr_${provider}_${random}`
+}
diff --git a/docs/asr-provider-layer.md b/docs/asr-provider-layer.md
new file mode 100644
index 0000000..05117ca
--- /dev/null
+++ b/docs/asr-provider-layer.md
@@ -0,0 +1,312 @@
+# ASR Provider Layer
+
+This document is the executable implementation guide for the shared ASR provider layer. It defines the contracts, files, provider responsibilities, rollout steps, and test plan for moving speech recognition from a mobile-only native path to a shared provider architecture.
+
+## Current Status
+
+- Implemented: shared ASR types, provider capability registry, API client methods, server provider registry, `/api/asr/providers`, `/api/asr/session`, and contract tests.
+- Not implemented yet: provider-specific remote ASR signing, WebSocket relay, audio upload, and production mobile session switching.
+- Production behavior remains unchanged: mobile live sessions still use `expo-speech-recognition` through `apps/mobile/src/nativeSpeech.ts`.
+
+## Target Outcome
+
+The final user-visible goal is faster and more accurate voice input while keeping the session state machine stable:
+
+1. Mobile can query which ASR providers are available before a session starts.
+2. Mobile can request a provider session without knowing provider secrets.
+3. Server can bootstrap remote ASR providers with signed URLs or short-lived tokens.
+4. Session code can switch between native ASR and remote ASR through one interface.
+5. Metrics can compare native ASR latency and remote ASR latency before changing the default provider.
+
+## Architecture
+
+```mermaid
+flowchart TD
+ MobileSession[apps/mobile session screen] --> ApiClient[packages/api-client]
+ WebSession[apps/web session UI] --> ApiClient
+ ApiClient --> ProvidersRoute[/api/asr/providers]
+ ApiClient --> SessionRoute[/api/asr/session]
+ ProvidersRoute --> ServerRegistry[apps/web/lib/server/asr.ts]
+ SessionRoute --> ServerRegistry
+ ServerRegistry --> SharedContracts[packages/shared/src/asr.ts]
+ ServerRegistry --> XunfeiSigner[future xunfei signer]
+ ServerRegistry --> AzureSigner[future azure signer]
+ ServerRegistry --> TencentSigner[future tencent signer]
+ ServerRegistry --> VolcengineSigner[future volcengine signer]
+```
+
+## Runtime Flow
+
+```mermaid
+sequenceDiagram
+ participant App as Mobile/Web Client
+ participant Client as packages/api-client
+ participant API as Web API Route
+ participant Registry as ASR Registry
+ participant Provider as Remote ASR Provider
+
+ App->>Client: listASRProviders()
+ Client->>API: GET /api/asr/providers
+ API->>Registry: getASRProviders()
+ Registry-->>API: descriptors without secrets
+ API-->>Client: providers + default_provider
+ Client-->>App: available ASR choices
+
+ App->>Client: createASRSession(config)
+ Client->>API: POST /api/asr/session
+ API->>Registry: createASRSessionFromRequest()
+ alt native provider
+ Registry-->>API: unsupported native bootstrap
+ API-->>App: keep local STT path
+ else configured remote provider
+ Registry->>Provider: future signed bootstrap
+ Provider-->>Registry: future endpoint/token
+ Registry-->>API: short-lived session details
+ API-->>App: connect to remote ASR
+ else missing provider config
+ Registry-->>API: 400 provider_not_configured
+ end
+```
+
+## Files And Responsibilities
+
+### `packages/shared/src/asr.ts`
+
+Owns platform-neutral contracts. This file must not import web, mobile, Node-only, or provider SDK code.
+
+Exported types and helpers:
+
+- `ASRProviderKey`: `native | xunfei | azure | tencent | volcengine`.
+- `ASRMode`: `single_utterance | streaming | file`.
+- `ASRLanguageMode`: `english | mandarin | mixed_zh_en | auto`.
+- `ASRProviderCapability`: provider capability declaration.
+- `ASRProviderDescriptor`: public provider metadata returned to clients.
+- `ASRSessionConfig`: normalized runtime options.
+- `ASRSessionBootstrapRequest`: client request body for `/api/asr/session`.
+- `ASRSessionBootstrapResponse`: server response for a provider session.
+- `ASRTranscriptEvent`: common transcript event shape for later native/remote adapters.
+- `asrProviderCapabilities`: source of truth for planned provider capability.
+- `createASRProviderDescriptor(key, options)`: creates safe public descriptors.
+- `normalizeASRProviderKey(value, fallback)`: validates external input.
+- `normalizeASRSessionConfig(input)`: clamps mode, language, endpoint, and duration settings.
+
+### `packages/api-client/src/types.ts`
+
+Owns API DTOs used by web and mobile:
+
+- `ListASRProvidersResponse`
+- `CreateASRSessionRequest`
+- `CreateASRSessionResponse`
+
+### `packages/api-client/src/client.ts`
+
+Owns client calls:
+
+- `listASRProviders()` calls `GET /api/asr/providers`.
+- `createASRSession(input)` calls `POST /api/asr/session`.
+
+### `apps/web/lib/server/asr.ts`
+
+Owns server-side registry and provider bootstrap orchestration.
+
+Current functions:
+
+- `getASRProviders()`: returns provider descriptors. It never returns secrets.
+- `getDefaultASRProvider()`: resolves `ASR_PROVIDER`, falling back to `native`.
+- `createASRSessionFromRequest(input)`: validates provider availability and returns either native unsupported bootstrap, missing config errors, or a 501 remote bootstrap placeholder.
+
+Future provider-specific implementation should add small server-only helpers under `apps/web/lib/providers/`, for example:
+
+- `apps/web/lib/providers/xunfei-asr.ts`
+- `apps/web/lib/providers/azure-asr.ts`
+- `apps/web/lib/providers/tencent-asr.ts`
+- `apps/web/lib/providers/volcengine-asr.ts`
+
+Those helpers should return an `ASRSessionBootstrapResponse` and keep provider SDK/signature details out of `packages/shared`.
+
+### `apps/web/app/api/asr/providers/route.ts`
+
+Public provider discovery endpoint. Response:
+
+```json
+{
+ "providers": [],
+ "default_provider": "native"
+}
+```
+
+### `apps/web/app/api/asr/session/route.ts`
+
+Public bootstrap endpoint. Request shape:
+
+```json
+{
+ "provider": "xunfei",
+ "mode": "streaming",
+ "languageMode": "mixed_zh_en",
+ "scenarioKey": "small-talk",
+ "sessionId": "session-id",
+ "clientTraceId": "trace-id"
+}
+```
+
+Current remote provider response is intentionally `501` until a signer/relay exists.
+
+## Environment Variables
+
+Provider discovery checks these variables:
+
+- Default provider: `ASR_PROVIDER`
+- iFLYTEK: `XUNFEI_ASR_APP_ID`, `XUNFEI_ASR_API_KEY`, `XUNFEI_ASR_API_SECRET`
+- Azure: `AZURE_SPEECH_KEY`, `AZURE_SPEECH_REGION`
+- Tencent Cloud: `TENCENT_SECRET_ID`, `TENCENT_SECRET_KEY`
+- Volcengine: `VOLCENGINE_ASR_APP_ID`, `VOLCENGINE_ASR_ACCESS_TOKEN`
+
+Never expose these values to the client. API responses may expose only configured/enabled state and missing env var names.
+
+## Rollout Steps
+
+### P1: Shared Provider Layer
+
+Goal: create stable contracts and discovery APIs without changing live session behavior.
+
+Implementation:
+
+1. Add `packages/shared/src/asr.ts`.
+2. Export `./asr` from `packages/shared/package.json`.
+3. Export ASR contracts from `packages/shared/src/index.ts`.
+4. Add API client types and methods.
+5. Add web server registry and API routes.
+6. Add contract tests.
+
+Done when:
+
+- `npm test` passes.
+- `GET /api/asr/providers` returns all planned providers.
+- `POST /api/asr/session` returns native unsupported bootstrap for `native`.
+- Remote providers return explicit 400 or 501, not silent fallback.
+
+### P2: Provider Bootstrap
+
+Goal: server can create short-lived remote ASR sessions.
+
+Implementation:
+
+1. Pick one provider for the first real adapter, based on accuracy, cost, and network quality. Do not assume it must be the current TTS provider.
+2. Create one provider file under `apps/web/lib/providers/-asr.ts`.
+3. Add a function with this shape:
+
+```ts
+export async function createXunfeiASRSession(config: ASRSessionConfig): Promise
+```
+
+4. Validate required env vars in the provider file.
+5. Generate a short-lived signed WebSocket URL or token.
+6. Return only endpoint, safe headers, query params, expiration, and normalized config.
+7. Route `createASRSessionFromRequest()` to the provider function.
+
+Done when:
+
+- Configured provider returns `status: "created"`.
+- Missing credentials return 400 with a readable error.
+- No server secret appears in response JSON, logs, or client bundles.
+
+### P3: Client Adapter Interface
+
+Goal: mobile/web session code can consume native or remote ASR through one adapter boundary.
+
+Implementation:
+
+1. Add a session-side interface in the platform layer:
+
+```ts
+type ASRRuntimeAdapter = {
+ start(config: ASRSessionConfig): Promise
+ stop(reason?: string): Promise
+ onEvent(listener: (event: ASRTranscriptEvent) => void): () => void
+}
+```
+
+2. Keep `nativeSpeech.ts` as the `native` adapter.
+3. Add a remote adapter that calls `createASRSession()` and connects to provider transport.
+4. Feed `ASRTranscriptEvent` into the existing turn guard only when `event.type === "final"` and `event.isFinal === true`.
+5. Preserve the current TTS/STT order rules in `docs/development-rules.md`.
+
+Done when:
+
+- Current native path behaves the same as before.
+- Remote provider can be enabled behind a config flag.
+- TTS playback still blocks user transcript handling.
+- Each real utterance creates at most one AI turn.
+
+### P4: Latency And Accuracy Evaluation
+
+Goal: compare native ASR and remote ASR before changing defaults.
+
+Required metrics:
+
+- `asr_provider_selected`
+- `asr_session_bootstrap_start`
+- `asr_session_bootstrap_end`
+- `asr_speech_start`
+- `asr_partial`
+- `asr_final`
+- `asr_no_speech_timeout`
+- `asr_error`
+- `turn_locked`
+- `ai_reply_start`
+- `tts_playback_start`
+
+Evaluation method:
+
+1. Test native ASR and one remote provider with the same scripted prompts.
+2. Include Chinese, English, and mixed Chinese-English utterances.
+3. Measure time from speech end to `asr_final`.
+4. Measure time from `asr_final` to `ai_reply_start`.
+5. Compare transcript quality manually for names, mixed language, and punctuation.
+
+Done when:
+
+- Remote ASR improves transcript quality enough to justify network cost.
+- Speech-end to final latency is acceptable on a real device in China.
+- Session does not get stuck after silence, background/foreground, or page transitions.
+
+## Testing
+
+Automated:
+
+```bash
+npm run lint
+npm test
+npm run mobile:config
+```
+
+Manual API checks:
+
+```bash
+curl -s http://127.0.0.1:3001/api/asr/providers
+curl -s -X POST http://127.0.0.1:3001/api/asr/session \
+ -H 'Content-Type: application/json' \
+ -d '{"provider":"native"}'
+```
+
+Manual mobile checks after production wiring:
+
+1. Start a session and say one short sentence.
+2. Confirm one final transcript creates one AI reply.
+3. Stay silent for 20 seconds; session must remain ready.
+4. Play TTS; ASR must not treat TTS audio as user speech.
+5. Background and foreground the app; session must recover without duplicate turns.
+6. Leave `/session` and return; active sessions may resume, ended sessions must not.
+
+## Provider Selection Rules
+
+Default behavior:
+
+1. Use `ASR_PROVIDER` if configured and enabled.
+2. Fall back to `native`.
+3. Never silently fall back from a requested remote provider to another remote provider.
+4. Return explicit 400 for missing credentials.
+5. Return explicit 501 for configured providers whose signer is not implemented.
+
+Provider choice should be based on measured quality and latency, not on the current TTS vendor.
diff --git a/docs/index.md b/docs/index.md
index b531f44..f2e5195 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -14,6 +14,8 @@
- Web + Native Mobile 双端架构升级历史路线、mobile 探针、共享包和 session core 落地计划。
5. `docs/session-endpointing-vad.md`
- 会话页“用户是否说完”的 endpointing + VAD 设计,修改 STT/VAD/静音等待/轮次触发前 MUST 阅读。
+6. `docs/asr-provider-layer.md`
+ - 共享 ASR provider 层执行规格,修改 ASR provider、远端识别、mobile/web ASR 接入前 MUST 阅读。
## 当前执行状态
@@ -47,6 +49,8 @@
- 三合一层级判停方案:Layer 1 本地快速判断 + Layer 2 LLM 语义判停 + Layer 3 安全网超时。从固定静默时长升级为智能 end-of-turn 检测。
- `docs/voice-profile-unification-plan.md`
- 后续统一教练声音选择计划,覆盖 Xunfei/Azure 等 provider 的 voice profile 模型和 UI 方向。
+- `docs/asr-provider-layer.md`
+ - 共享 ASR provider 层落地文档,覆盖 provider 能力、API 契约、服务端 bootstrap、后续远端 ASR 接入和验收路径。
- `docs/deployment-runbook.md`
- Monorepo 迁移后的 Vercel 部署、分支职责、环境变量和发布/回滚流程。
diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts
index c7f5a84..eb1c466 100644
--- a/packages/api-client/src/client.ts
+++ b/packages/api-client/src/client.ts
@@ -2,6 +2,8 @@ import type {
ApiClientOptions,
ApiErrorBody,
ApiHeadersProvider,
+ CreateASRSessionRequest,
+ CreateASRSessionResponse,
CreateSessionRequest,
CreateSessionResponse,
CreateTurnRequest,
@@ -11,6 +13,7 @@ import type {
GenerateSummaryRequest,
GenerateSummaryResponse,
ListAccentsResponse,
+ ListASRProvidersResponse,
ListHistoryResponse,
ListScenariosResponse,
ListSessionTurnsResponse,
@@ -85,6 +88,17 @@ export class MeteorVoiceApiClient {
})
}
+ listASRProviders() {
+ return this.request('/api/asr/providers')
+ }
+
+ createASRSession(input: CreateASRSessionRequest) {
+ return this.request('/api/asr/session', {
+ method: 'POST',
+ body: input,
+ })
+ }
+
syncSession(input: SyncSessionRequest) {
return this.request('/api/session/sync', {
method: 'POST',
diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts
index 5ef84e6..4737c3a 100644
--- a/packages/api-client/src/types.ts
+++ b/packages/api-client/src/types.ts
@@ -1,5 +1,9 @@
import type {
AccentProfile,
+ ASRProviderDescriptor,
+ ASRProviderKey,
+ ASRSessionBootstrapRequest,
+ ASRSessionBootstrapResponse,
ConversationContext,
ConversationMessage,
ConversationResponse,
@@ -84,6 +88,15 @@ export type SynthesizeSpeechRequest = {
export type SynthesizeSpeechResponse = TTSResult
+export type ListASRProvidersResponse = {
+ providers: ASRProviderDescriptor[]
+ default_provider: ASRProviderKey
+}
+
+export type CreateASRSessionRequest = ASRSessionBootstrapRequest
+
+export type CreateASRSessionResponse = ASRSessionBootstrapResponse
+
export type SyncSessionRequest = {
session_id: string
scenario: string
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 1dd2ace..3fe80b3 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -8,6 +8,7 @@
"./conversation": "./src/conversation.ts",
"./locale": "./src/locale.ts",
"./scenarios": "./src/scenarios.ts",
- "./speech": "./src/speech.ts"
+ "./speech": "./src/speech.ts",
+ "./asr": "./src/asr.ts"
}
}
diff --git a/packages/shared/src/asr.ts b/packages/shared/src/asr.ts
new file mode 100644
index 0000000..80194fe
--- /dev/null
+++ b/packages/shared/src/asr.ts
@@ -0,0 +1,203 @@
+export type ASRProviderKey = 'native' | 'xunfei' | 'azure' | 'tencent' | 'volcengine'
+
+export type ASRMode = 'single_utterance' | 'streaming' | 'file'
+
+export type ASRLanguageMode = 'english' | 'mandarin' | 'mixed_zh_en' | 'auto'
+
+export type ASRSessionStatus = 'created' | 'unsupported' | 'provider_not_configured'
+
+export type ASRTransport = 'native' | 'websocket' | 'http'
+
+export type ASRProviderCapability = {
+ modes: ASRMode[]
+ languageModes: ASRLanguageMode[]
+ transports: ASRTransport[]
+ supportsPunctuation: boolean
+ supportsInterimResults: boolean
+ supportsEndpointing: boolean
+ supportsNoiseReduction: boolean
+ supportsServerBootstrap: boolean
+ recommendedForMobile: boolean
+}
+
+export type ASRProviderDescriptor = {
+ key: ASRProviderKey
+ label: string
+ configured: boolean
+ enabled: boolean
+ capability: ASRProviderCapability
+ disabledReason?: string
+}
+
+export type ASRSessionConfig = {
+ provider: ASRProviderKey
+ mode: ASRMode
+ languageMode: ASRLanguageMode
+ locale?: string
+ scenarioKey?: string
+ sessionId?: string
+ userId?: string
+ enableInterimResults?: boolean
+ enablePunctuation?: boolean
+ endpointSilenceMs?: number
+ maxDurationMs?: number
+ clientTraceId?: string
+}
+
+export type ASRSessionBootstrapRequest = Partial & {
+ provider: ASRProviderKey
+}
+
+export type ASRSessionBootstrapResponse = {
+ provider: ASRProviderKey
+ status: ASRSessionStatus
+ sessionId: string
+ transport: ASRTransport
+ expiresAt?: string
+ endpointUrl?: string
+ headers?: Record
+ query?: Record
+ config: ASRSessionConfig
+}
+
+export type ASRTranscriptEventType = 'speech_start' | 'partial' | 'final' | 'speech_end' | 'error'
+
+export type ASRTranscriptEvent = {
+ type: ASRTranscriptEventType
+ provider: ASRProviderKey
+ sessionId: string
+ transcript?: string
+ isFinal: boolean
+ confidence?: number
+ elapsedMs?: number
+ language?: string
+ error?: string
+}
+
+export const asrProviderCapabilities: Record = {
+ native: {
+ modes: ['single_utterance'],
+ languageModes: ['english', 'mandarin', 'mixed_zh_en', 'auto'],
+ transports: ['native'],
+ supportsPunctuation: true,
+ supportsInterimResults: true,
+ supportsEndpointing: false,
+ supportsNoiseReduction: false,
+ supportsServerBootstrap: false,
+ recommendedForMobile: true,
+ },
+ xunfei: {
+ modes: ['single_utterance', 'streaming', 'file'],
+ languageModes: ['english', 'mandarin', 'mixed_zh_en'],
+ transports: ['websocket', 'http'],
+ supportsPunctuation: true,
+ supportsInterimResults: true,
+ supportsEndpointing: true,
+ supportsNoiseReduction: true,
+ supportsServerBootstrap: true,
+ recommendedForMobile: true,
+ },
+ azure: {
+ modes: ['single_utterance', 'streaming', 'file'],
+ languageModes: ['english', 'mandarin', 'mixed_zh_en', 'auto'],
+ transports: ['websocket', 'http'],
+ supportsPunctuation: true,
+ supportsInterimResults: true,
+ supportsEndpointing: true,
+ supportsNoiseReduction: true,
+ supportsServerBootstrap: true,
+ recommendedForMobile: true,
+ },
+ tencent: {
+ modes: ['single_utterance', 'streaming', 'file'],
+ languageModes: ['english', 'mandarin', 'mixed_zh_en'],
+ transports: ['websocket', 'http'],
+ supportsPunctuation: true,
+ supportsInterimResults: true,
+ supportsEndpointing: true,
+ supportsNoiseReduction: true,
+ supportsServerBootstrap: true,
+ recommendedForMobile: true,
+ },
+ volcengine: {
+ modes: ['single_utterance', 'streaming', 'file'],
+ languageModes: ['english', 'mandarin', 'mixed_zh_en'],
+ transports: ['websocket', 'http'],
+ supportsPunctuation: true,
+ supportsInterimResults: true,
+ supportsEndpointing: true,
+ supportsNoiseReduction: true,
+ supportsServerBootstrap: true,
+ recommendedForMobile: true,
+ },
+}
+
+export const asrProviderLabels: Record = {
+ native: 'Device speech recognition',
+ xunfei: 'iFLYTEK ASR',
+ azure: 'Azure Speech to Text',
+ tencent: 'Tencent Cloud ASR',
+ volcengine: 'Volcengine ASR',
+}
+
+export const asrProviderKeys = Object.keys(asrProviderCapabilities) as ASRProviderKey[]
+
+export function isASRProviderKey(value: unknown): value is ASRProviderKey {
+ return typeof value === 'string' && asrProviderKeys.includes(value as ASRProviderKey)
+}
+
+export function normalizeASRProviderKey(value: unknown, fallback: ASRProviderKey = 'native'): ASRProviderKey {
+ return isASRProviderKey(value) ? value : fallback
+}
+
+export function createASRProviderDescriptor(
+ key: ASRProviderKey,
+ options: {
+ configured?: boolean
+ enabled?: boolean
+ disabledReason?: string
+ } = {},
+): ASRProviderDescriptor {
+ const configured = options.configured ?? key === 'native'
+ const enabled = options.enabled ?? configured
+ return {
+ key,
+ label: asrProviderLabels[key],
+ configured,
+ enabled,
+ capability: asrProviderCapabilities[key],
+ disabledReason: enabled ? undefined : options.disabledReason ?? 'Provider is not configured',
+ }
+}
+
+export function normalizeASRSessionConfig(input: ASRSessionBootstrapRequest): ASRSessionConfig {
+ const provider = normalizeASRProviderKey(input.provider)
+ const capability = asrProviderCapabilities[provider]
+ const mode = capability.modes.includes(input.mode as ASRMode) ? input.mode as ASRMode : capability.modes[0]
+ const languageMode = capability.languageModes.includes(input.languageMode as ASRLanguageMode)
+ ? input.languageMode as ASRLanguageMode
+ : defaultLanguageMode(provider)
+
+ return {
+ provider,
+ mode,
+ languageMode,
+ locale: input.locale,
+ scenarioKey: input.scenarioKey,
+ sessionId: input.sessionId,
+ userId: input.userId,
+ enableInterimResults: input.enableInterimResults ?? capability.supportsInterimResults,
+ enablePunctuation: input.enablePunctuation ?? capability.supportsPunctuation,
+ endpointSilenceMs: normalizePositiveInteger(input.endpointSilenceMs, 900),
+ maxDurationMs: normalizePositiveInteger(input.maxDurationMs, 60000),
+ clientTraceId: input.clientTraceId,
+ }
+}
+
+function defaultLanguageMode(provider: ASRProviderKey): ASRLanguageMode {
+ return provider === 'native' || provider === 'azure' ? 'auto' : 'mixed_zh_en'
+}
+
+function normalizePositiveInteger(value: unknown, fallback: number) {
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : fallback
+}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 710feae..c264a0f 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -3,3 +3,4 @@ export * from './i18n'
export * from './locale'
export * from './scenarios'
export * from './speech'
+export * from './asr'
diff --git a/tests/api-client.test.ts b/tests/api-client.test.ts
index 0f5605c..8ad5bfd 100644
--- a/tests/api-client.test.ts
+++ b/tests/api-client.test.ts
@@ -86,4 +86,31 @@ describe('MeteorVoiceApiClient', () => {
expect(result.turns).toEqual([])
expect(calls[0]).toBe('https://example.com/api/sessions/s%201/turns')
})
+
+ it('exposes ASR provider listing and session bootstrap routes', async () => {
+ const calls: { input: string; init?: RequestInit }[] = []
+ const client = createMeteorVoiceApiClient({
+ baseUrl: 'https://example.com',
+ fetch: async (input, init) => {
+ calls.push({ input: String(input), init })
+ return new Response(JSON.stringify({
+ providers: [],
+ default_provider: 'native',
+ provider: 'native',
+ status: 'unsupported',
+ sessionId: 'asr_native_1',
+ transport: 'native',
+ config: { provider: 'native', mode: 'single_utterance', languageMode: 'auto' },
+ }), { status: 200 })
+ },
+ })
+
+ await client.listASRProviders()
+ await client.createASRSession({ provider: 'native' })
+
+ expect(calls[0].input).toBe('https://example.com/api/asr/providers')
+ expect(calls[1].input).toBe('https://example.com/api/asr/session')
+ expect(calls[1].init?.method).toBe('POST')
+ expect(calls[1].init?.body).toBe(JSON.stringify({ provider: 'native' }))
+ })
})
diff --git a/tests/asr-provider.test.ts b/tests/asr-provider.test.ts
new file mode 100644
index 0000000..bf26388
--- /dev/null
+++ b/tests/asr-provider.test.ts
@@ -0,0 +1,99 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ asrProviderCapabilities,
+ createASRProviderDescriptor,
+ normalizeASRProviderKey,
+ normalizeASRSessionConfig,
+} from '@meteorvoice/shared'
+import { createASRSessionFromRequest, getASRProviders, getDefaultASRProvider } from '@/lib/server/asr'
+
+const originalEnv = { ...process.env }
+
+afterEach(() => {
+ process.env = { ...originalEnv }
+ vi.unstubAllGlobals()
+})
+
+describe('ASR provider contracts', () => {
+ it('normalizes unknown provider input to native', () => {
+ expect(normalizeASRProviderKey('xunfei')).toBe('xunfei')
+ expect(normalizeASRProviderKey('unknown')).toBe('native')
+ })
+
+ it('publishes expected capabilities for all planned providers', () => {
+ expect(Object.keys(asrProviderCapabilities)).toEqual(['native', 'xunfei', 'azure', 'tencent', 'volcengine'])
+ expect(asrProviderCapabilities.xunfei.modes).toContain('streaming')
+ expect(asrProviderCapabilities.azure.languageModes).toContain('auto')
+ expect(asrProviderCapabilities.native.supportsServerBootstrap).toBe(false)
+ })
+
+ it('creates safe public descriptors without secrets', () => {
+ const descriptor = createASRProviderDescriptor('xunfei', {
+ configured: false,
+ enabled: false,
+ disabledReason: 'Missing XUNFEI_ASR_APP_ID',
+ })
+
+ expect(descriptor.key).toBe('xunfei')
+ expect(descriptor.enabled).toBe(false)
+ expect(JSON.stringify(descriptor)).not.toContain('secret')
+ })
+
+ it('normalizes session config against provider capability', () => {
+ const config = normalizeASRSessionConfig({
+ provider: 'xunfei',
+ mode: 'streaming',
+ languageMode: 'auto',
+ endpointSilenceMs: -1,
+ })
+
+ expect(config.mode).toBe('streaming')
+ expect(config.languageMode).toBe('mixed_zh_en')
+ expect(config.endpointSilenceMs).toBe(900)
+ expect(config.enableInterimResults).toBe(true)
+ })
+})
+
+describe('ASR server registry', () => {
+ it('always exposes native and marks remote providers disabled until configured', () => {
+ delete process.env.ASR_PROVIDER
+ delete process.env.XUNFEI_ASR_APP_ID
+ delete process.env.XUNFEI_ASR_API_KEY
+ delete process.env.XUNFEI_ASR_API_SECRET
+
+ const providers = getASRProviders()
+ expect(providers.find(provider => provider.key === 'native')).toMatchObject({ enabled: true, configured: true })
+ expect(providers.find(provider => provider.key === 'xunfei')).toMatchObject({ enabled: false, configured: false })
+ expect(getDefaultASRProvider()).toBe('native')
+ })
+
+ it('accepts configured remote providers but keeps bootstrap explicit until signer exists', () => {
+ process.env.ASR_PROVIDER = 'xunfei'
+ process.env.XUNFEI_ASR_APP_ID = 'app-id'
+ process.env.XUNFEI_ASR_API_KEY = 'api-key'
+ process.env.XUNFEI_ASR_API_SECRET = 'api-secret'
+
+ expect(getDefaultASRProvider()).toBe('xunfei')
+ expect(createASRSessionFromRequest({ provider: 'xunfei', mode: 'streaming' })).toMatchObject({
+ error: 'ASR server bootstrap is not implemented for this provider yet',
+ status: 501,
+ })
+ })
+
+ it('returns native unsupported bootstrap so mobile keeps using local STT for now', () => {
+ vi.stubGlobal('crypto', { randomUUID: () => 'fixed-id' })
+
+ expect(createASRSessionFromRequest({ provider: 'native' })).toMatchObject({
+ provider: 'native',
+ status: 'unsupported',
+ sessionId: 'asr_native_fixed-id',
+ transport: 'native',
+ config: {
+ provider: 'native',
+ mode: 'single_utterance',
+ languageMode: 'auto',
+ sessionId: 'asr_native_fixed-id',
+ },
+ })
+ })
+})
diff --git a/vitest.config.ts b/vitest.config.ts
index 7134fa8..f951d8a 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -17,6 +17,7 @@ export default defineConfig({
'@meteorvoice/shared/locale': path.resolve(__dirname, 'packages/shared/src/locale.ts'),
'@meteorvoice/shared/scenarios': path.resolve(__dirname, 'packages/shared/src/scenarios.ts'),
'@meteorvoice/shared/speech': path.resolve(__dirname, 'packages/shared/src/speech.ts'),
+ '@meteorvoice/shared/asr': path.resolve(__dirname, 'packages/shared/src/asr.ts'),
'@': path.resolve(__dirname, 'apps/web'),
},
},
From 0df68b6a486829a0b84114cd02f39c28f53f7041 Mon Sep 17 00:00:00 2001
From: ConnorQi
Date: Sun, 31 May 2026 21:07:11 +0800
Subject: [PATCH 02/37] Add Xunfei ASR bootstrap and API guard
---
apps/mobile/src/App.tsx | 2 +-
apps/web/app/api/asr/session/route.ts | 5 +-
apps/web/app/api/chat/route.ts | 4 +-
apps/web/app/api/preferences/route.ts | 8 ++-
apps/web/app/api/semantic-endpoint/route.ts | 4 +-
apps/web/app/api/tts/route.ts | 4 +-
apps/web/app/settings/page.tsx | 4 +-
apps/web/components/VoiceSessionProvider.tsx | 10 +--
apps/web/lib/providers/xunfei-asr.ts | 68 +++++++++++++++++++
apps/web/lib/server/asr.ts | 7 +-
apps/web/lib/server/http.ts | 70 ++++++++++++++++++++
apps/web/lib/tts-speed.ts | 8 ++-
docs/asr-provider-layer.md | 57 ++++++++++++++--
packages/api-client/src/client.ts | 3 +
packages/shared/src/asr.ts | 31 +++++++++
tests/api-client.test.ts | 1 +
tests/api-guard.test.ts | 56 ++++++++++++++++
tests/asr-provider.test.ts | 26 ++++++--
18 files changed, 340 insertions(+), 28 deletions(-)
create mode 100644 apps/web/lib/providers/xunfei-asr.ts
create mode 100644 tests/api-guard.test.ts
diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index 8593a57..518e8ca 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -552,7 +552,7 @@ function AppInner() {
semanticCheck: auth.state === 'signed-in' ? async (t, ctx) => {
const res = await fetch(`${baseUrl}/api/semantic-endpoint`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
+ headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-mobile', ...getAuthHeaders() },
body: JSON.stringify({ transcript: t, messages: ctx.messages, scenario: ctx.scenario }),
})
if (!res.ok) throw new Error('Semantic check failed')
diff --git a/apps/web/app/api/asr/session/route.ts b/apps/web/app/api/asr/session/route.ts
index bb97d95..540515a 100644
--- a/apps/web/app/api/asr/session/route.ts
+++ b/apps/web/app/api/asr/session/route.ts
@@ -1,4 +1,5 @@
import { jsonApiResult, jsonServerError } from '@/lib/server/http'
+import { guardApiRequest } from '@/lib/server/http'
import { createASRSessionFromRequest } from '@/lib/server/asr'
import type { ASRSessionBootstrapRequest } from '@meteorvoice/shared'
@@ -6,8 +7,10 @@ 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 body = await request.json() as ASRSessionBootstrapRequest
- return jsonApiResult(createASRSessionFromRequest(body))
+ 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..fffff18 100644
--- a/apps/web/app/api/chat/route.ts
+++ b/apps/web/app/api/chat/route.ts
@@ -1,9 +1,11 @@
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 } 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 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..9eb5feb 100644
--- a/apps/web/app/api/preferences/route.ts
+++ b/apps/web/app/api/preferences/route.ts
@@ -1,8 +1,10 @@
-import { jsonApiResult, jsonServerError } from '@/lib/server/http'
+import { guardApiRequest, jsonApiResult, jsonServerError } 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: 120 })
+ if (guard) return jsonApiResult(guard)
return jsonApiResult(await getPreferences())
} catch (error) {
return jsonServerError(error, 'Failed to load preferences')
@@ -11,6 +13,8 @@ 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 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..86a6e42 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 } from '@/lib/server/http'
import { createSemanticEndpointCheck } from '@/lib/server/semantic-endpoint'
const MAX_TRANSCRIPT_LENGTH = 2000
@@ -28,6 +28,8 @@ 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 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..4119d2a 100644
--- a/apps/web/app/api/tts/route.ts
+++ b/apps/web/app/api/tts/route.ts
@@ -1,10 +1,12 @@
-import { jsonApiResult, jsonServerError } from '@/lib/server/http'
+import { guardApiRequest, jsonApiResult, jsonServerError } 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 body = await request.json() as {
text?: string
accent?: string
diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx
index 882a675..4c54027 100644
--- a/apps/web/app/settings/page.tsx
+++ b/apps/web/app/settings/page.tsx
@@ -53,7 +53,9 @@ export default function SettingsPage() {
useEffect(() => {
async function loadTtsProvider() {
try {
- const res = await fetch('/api/preferences')
+ const res = await fetch('/api/preferences', {
+ headers: { 'X-MeteorVoice-Client': 'meteorvoice-web' },
+ })
const data = await res.json() as {
tts_provider?: string
available_providers?: string[]
diff --git a/apps/web/components/VoiceSessionProvider.tsx b/apps/web/components/VoiceSessionProvider.tsx
index ac214c2..393f1b2 100644
--- a/apps/web/components/VoiceSessionProvider.tsx
+++ b/apps/web/components/VoiceSessionProvider.tsx
@@ -626,7 +626,9 @@ export default function VoiceSessionProvider({ children }: { children: ReactNode
useEffect(() => {
void flushPendingPreferences()
- fetch('/api/preferences')
+ fetch('/api/preferences', {
+ headers: { 'X-MeteorVoice-Client': 'meteorvoice-web' },
+ })
.then(res => res.json())
.then((data: { tts_provider?: string; tts_speed?: number; tts_voice_id?: string | null }) => {
if (data.tts_provider) setTtsProvider(data.tts_provider)
@@ -892,7 +894,7 @@ export default function VoiceSessionProvider({ children }: { children: ReactNode
const speedRouting = getTTSSpeedRouting(provider, speed)
const res = await fetch('/api/tts', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-web' },
body: JSON.stringify({ text: speechText, accent: accentName, provider, speed: speedRouting.serverSpeed, voiceId: ttsVoiceIdRef.current }),
})
const result = await res.json() as { audioUrl?: string; error?: string }
@@ -1156,7 +1158,7 @@ export default function VoiceSessionProvider({ children }: { children: ReactNode
semanticCheck: async (t, ctx) => {
const res = await fetch('/api/semantic-endpoint', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-web' },
body: JSON.stringify({ transcript: t, messages: ctx.messages, scenario: ctx.scenario }),
})
if (!res.ok) throw new Error('Semantic check failed')
@@ -1199,7 +1201,7 @@ export default function VoiceSessionProvider({ children }: { children: ReactNode
try {
const res = await fetch('/api/chat', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-web' },
body: JSON.stringify({
messages: acceptedTurn.messages,
context: {
diff --git a/apps/web/lib/providers/xunfei-asr.ts b/apps/web/lib/providers/xunfei-asr.ts
new file mode 100644
index 0000000..9665317
--- /dev/null
+++ b/apps/web/lib/providers/xunfei-asr.ts
@@ -0,0 +1,68 @@
+import crypto from 'crypto'
+import type { ASRSessionBootstrapResponse, ASRSessionConfig } from '@meteorvoice/shared'
+
+const zhIatHost = 'iat.xf-yun.com'
+const zhIatPath = '/v1'
+
+function requireEnv(name: string) {
+ const value = process.env[name]?.trim()
+ if (!value) throw new Error(`${name} is required for Xunfei ASR`)
+ return value
+}
+
+function createAuthUrl(apiKey: string, apiSecret: string) {
+ const date = new Date().toUTCString()
+ const signatureOrigin = `host: ${zhIatHost}\ndate: ${date}\nGET ${zhIatPath} HTTP/1.1`
+ const signature = crypto
+ .createHmac('sha256', apiSecret)
+ .update(signatureOrigin)
+ .digest('base64')
+ const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`
+ const authorization = Buffer.from(authorizationOrigin).toString('base64')
+
+ return `wss://${zhIatHost}${zhIatPath}?authorization=${encodeURIComponent(authorization)}&date=${encodeURIComponent(date)}&host=${zhIatHost}`
+}
+
+export async function createXunfeiASRSession(config: ASRSessionConfig): Promise {
+ const appId = requireEnv('XUNFEI_ASR_APP_ID')
+ const apiKey = requireEnv('XUNFEI_ASR_API_KEY')
+ const apiSecret = requireEnv('XUNFEI_ASR_API_SECRET')
+ const product = process.env.XUNFEI_ASR_PRODUCT?.trim() || 'zh_iat'
+
+ if (product !== 'zh_iat') {
+ throw new Error(`Unsupported Xunfei ASR product: ${product}`)
+ }
+
+ const now = Date.now()
+ const sessionId = config.sessionId ?? `asr_xunfei_${crypto.randomUUID()}`
+ const eosMs = Math.min(6000, Math.max(600, config.endpointSilenceMs ?? 900))
+
+ return {
+ provider: 'xunfei',
+ status: 'created',
+ sessionId,
+ transport: 'websocket',
+ endpointUrl: createAuthUrl(apiKey, apiSecret),
+ expiresAt: new Date(now + 4 * 60 * 1000).toISOString(),
+ providerConfig: {
+ appId,
+ domain: 'slm',
+ language: 'zh_cn',
+ accent: 'mandarin',
+ eosMs,
+ audioEncoding: 'raw',
+ sampleRate: 16000,
+ channels: 1,
+ bitDepth: 16,
+ frameIntervalMs: 40,
+ frameSizeBytes: 1280,
+ },
+ config: {
+ ...config,
+ sessionId,
+ provider: 'xunfei',
+ mode: config.mode === 'streaming' ? 'streaming' : 'single_utterance',
+ languageMode: config.languageMode === 'english' ? 'english' : 'mixed_zh_en',
+ },
+ }
+}
diff --git a/apps/web/lib/server/asr.ts b/apps/web/lib/server/asr.ts
index eb9b890..ab2e04b 100644
--- a/apps/web/lib/server/asr.ts
+++ b/apps/web/lib/server/asr.ts
@@ -8,6 +8,7 @@ import {
type ASRSessionBootstrapRequest,
type ASRSessionBootstrapResponse,
} from '@meteorvoice/shared'
+import { createXunfeiASRSession } from '@/lib/providers/xunfei-asr'
const serverBootstrapPendingMessage = 'ASR server bootstrap is not implemented for this provider yet'
@@ -29,7 +30,7 @@ export function getDefaultASRProvider(): ASRProviderKey {
return provider?.enabled ? provider.key : 'native'
}
-export function createASRSessionFromRequest(input: ASRSessionBootstrapRequest) {
+export async function createASRSessionFromRequest(input: ASRSessionBootstrapRequest) {
const provider = normalizeASRProviderKey(input.provider, getDefaultASRProvider())
const descriptor = getASRProviders().find(item => item.key === provider)
@@ -53,6 +54,10 @@ export function createASRSessionFromRequest(input: ASRSessionBootstrapRequest) {
} satisfies ASRSessionBootstrapResponse
}
+ if (provider === 'xunfei') {
+ return createXunfeiASRSession(config)
+ }
+
return {
error: serverBootstrapPendingMessage,
status: 501 as const,
diff --git a/apps/web/lib/server/http.ts b/apps/web/lib/server/http.ts
index 50d34e7..5ff29be 100644
--- a/apps/web/lib/server/http.ts
+++ b/apps/web/lib/server/http.ts
@@ -5,6 +5,22 @@ export type ApiErrorResult = {
status: number
}
+type GuardOptions = {
+ name: string
+ windowMs: number
+ maxRequests: number
+ requireClientHeader?: boolean
+}
+
+type RateBucket = {
+ count: number
+ resetAt: number
+}
+
+const rateBuckets = new Map()
+const trustedClientHeaders = new Set(['meteorvoice-api-client', 'meteorvoice-web', 'meteorvoice-mobile'])
+const defaultBlockedCountries = ['JP']
+
export function isApiErrorResult(value: unknown): value is ApiErrorResult {
return Boolean(
value &&
@@ -26,6 +42,36 @@ export function jsonServerError(error: unknown, fallback = 'Internal server erro
return NextResponse.json({ error: message }, { status: 500 })
}
+export function guardApiRequest(request: Request, options: GuardOptions): ApiErrorResult | null {
+ const country = request.headers.get('x-vercel-ip-country')?.toUpperCase()
+ if (country && getBlockedApiCountries().includes(country)) {
+ return { error: 'Request blocked', status: 403 }
+ }
+
+ if (options.requireClientHeader) {
+ const client = request.headers.get('x-meteorvoice-client')?.trim()
+ if (!client || !trustedClientHeaders.has(client)) {
+ return { error: 'Missing client identification', status: 403 }
+ }
+ }
+
+ const now = Date.now()
+ const key = `${options.name}:${getRequestIp(request)}`
+ const current = rateBuckets.get(key)
+ if (!current || current.resetAt <= now) {
+ rateBuckets.set(key, { count: 1, resetAt: now + options.windowMs })
+ pruneRateBuckets(now)
+ return null
+ }
+
+ current.count += 1
+ if (current.count > options.maxRequests) {
+ return { error: 'Too many requests', status: 429 }
+ }
+
+ return null
+}
+
function getErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message) return error.message
if (typeof error === 'string' && error.trim()) return error
@@ -40,3 +86,27 @@ function getErrorMessage(error: unknown, fallback: string) {
return [message ?? apiError, code, details, hint].filter(Boolean).join(' - ') || fallback
}
+
+function getBlockedApiCountries() {
+ const configured = process.env.API_GUARD_BLOCKED_COUNTRIES
+ if (!configured?.trim()) return defaultBlockedCountries
+ return configured
+ .split(',')
+ .map(country => country.trim().toUpperCase())
+ .filter(Boolean)
+}
+
+function getRequestIp(request: Request) {
+ const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
+ return request.headers.get('x-real-ip')?.trim() ||
+ request.headers.get('x-vercel-forwarded-for')?.split(',')[0]?.trim() ||
+ forwardedFor ||
+ 'unknown'
+}
+
+function pruneRateBuckets(now: number) {
+ if (rateBuckets.size < 2000) return
+ for (const [key, bucket] of rateBuckets) {
+ if (bucket.resetAt <= now) rateBuckets.delete(key)
+ }
+}
diff --git a/apps/web/lib/tts-speed.ts b/apps/web/lib/tts-speed.ts
index 89b4151..345a4ed 100644
--- a/apps/web/lib/tts-speed.ts
+++ b/apps/web/lib/tts-speed.ts
@@ -45,7 +45,7 @@ async function flushPendingPreferences() {
}
const res = await fetch('/api/preferences', {
method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-web' },
body: JSON.stringify(body),
})
if (res.ok) clearPendingSyncKeys()
@@ -66,7 +66,7 @@ export async function persistPreference(key: string, value: string | number) {
try {
const res = await fetch('/api/preferences', {
method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-web' },
body: JSON.stringify({ [key]: value }),
})
if (res.ok) {
@@ -94,7 +94,9 @@ export function readTTSSpeedPreference(): TTSSpeed {
if (raw !== null) {
const local = normalizeTTSSpeed(raw)
// 异步从 API 拉取最新值覆盖本地(如果 API 值不同)
- void fetch('/api/preferences')
+ void fetch('/api/preferences', {
+ headers: { 'X-MeteorVoice-Client': 'meteorvoice-web' },
+ })
.then(res => res.json())
.then((data: { tts_speed?: number }) => {
if (typeof data.tts_speed === 'number') {
diff --git a/docs/asr-provider-layer.md b/docs/asr-provider-layer.md
index 05117ca..3c71927 100644
--- a/docs/asr-provider-layer.md
+++ b/docs/asr-provider-layer.md
@@ -4,8 +4,8 @@ This document is the executable implementation guide for the shared ASR provider
## Current Status
-- Implemented: shared ASR types, provider capability registry, API client methods, server provider registry, `/api/asr/providers`, `/api/asr/session`, and contract tests.
-- Not implemented yet: provider-specific remote ASR signing, WebSocket relay, audio upload, and production mobile session switching.
+- Implemented: shared ASR types, provider capability registry, runtime adapter boundary, API client methods, server provider registry, `/api/asr/providers`, `/api/asr/session`, Xunfei `zh_iat` signed WebSocket bootstrap, API abuse guard, and contract tests.
+- Not implemented yet: mobile microphone PCM streaming to remote ASR, WebSocket relay, transcript event ingestion from remote ASR, and production mobile session switching.
- Production behavior remains unchanged: mobile live sessions still use `expo-speech-recognition` through `apps/mobile/src/nativeSpeech.ts`.
## Target Outcome
@@ -29,7 +29,7 @@ flowchart TD
ProvidersRoute --> ServerRegistry[apps/web/lib/server/asr.ts]
SessionRoute --> ServerRegistry
ServerRegistry --> SharedContracts[packages/shared/src/asr.ts]
- ServerRegistry --> XunfeiSigner[future xunfei signer]
+ ServerRegistry --> XunfeiSigner[xunfei zh_iat signer]
ServerRegistry --> AzureSigner[future azure signer]
ServerRegistry --> TencentSigner[future tencent signer]
ServerRegistry --> VolcengineSigner[future volcengine signer]
@@ -150,7 +150,36 @@ Public bootstrap endpoint. Request shape:
}
```
-Current remote provider response is intentionally `501` until a signer/relay exists.
+Xunfei `zh_iat` currently returns a short-lived signed WebSocket bootstrap response when `XUNFEI_ASR_*` credentials are configured. Other remote providers intentionally return `501` until a signer/relay exists.
+
+## Abuse Guard
+
+The ASR work also protects high-cost API routes because unauthenticated bot traffic can burn Vercel free resources and provider credits.
+
+Server helper:
+
+- File: `apps/web/lib/server/http.ts`
+- Function: `guardApiRequest(request, options)`
+- Checks:
+ - `x-vercel-ip-country` against `API_GUARD_BLOCKED_COUNTRIES`, defaulting to `JP`.
+ - Optional trusted client header: `X-MeteorVoice-Client`.
+ - In-memory per route/IP rate bucket.
+
+Trusted client header values:
+
+- `meteorvoice-api-client`
+- `meteorvoice-web`
+- `meteorvoice-mobile`
+
+Guarded routes:
+
+- `/api/chat`
+- `/api/tts`
+- `/api/semantic-endpoint`
+- `/api/preferences`
+- `/api/asr/session`
+
+This guard is a low-cost first line of defense. It is not a replacement for provider-side quotas, Vercel Firewall, or a durable distributed rate limiter. If abuse continues from many IPs, add a persistent limiter backed by Redis, Upstash, Supabase, or Vercel Firewall rules.
## Environment Variables
@@ -202,7 +231,7 @@ export async function createXunfeiASRSession(config: ASRSessionConfig): Promise<
4. Validate required env vars in the provider file.
5. Generate a short-lived signed WebSocket URL or token.
-6. Return only endpoint, safe headers, query params, expiration, and normalized config.
+6. Return only endpoint, safe headers, query params, expiration, normalized config, and non-secret provider runtime parameters.
7. Route `createASRSessionFromRequest()` to the provider function.
Done when:
@@ -211,16 +240,32 @@ Done when:
- Missing credentials return 400 with a readable error.
- No server secret appears in response JSON, logs, or client bundles.
+Current Xunfei implementation:
+
+- File: `apps/web/lib/providers/xunfei-asr.ts`
+- Function: `createXunfeiASRSession(config)`
+- Endpoint: `wss://iat.xf-yun.com/v1`
+- Product: `XUNFEI_ASR_PRODUCT=zh_iat`
+- Audio contract for the future client adapter:
+ - PCM/raw
+ - 16 kHz
+ - 16-bit
+ - mono
+ - 40 ms frame interval
+ - 1280 bytes per frame
+ - max user utterance target: 60 seconds
+
### P3: Client Adapter Interface
Goal: mobile/web session code can consume native or remote ASR through one adapter boundary.
Implementation:
-1. Add a session-side interface in the platform layer:
+1. Use the shared runtime interface:
```ts
type ASRRuntimeAdapter = {
+ provider: ASRProviderKey
start(config: ASRSessionConfig): Promise
stop(reason?: string): Promise
onEvent(listener: (event: ASRTranscriptEvent) => void): () => void
diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts
index eb1c466..84641d1 100644
--- a/packages/api-client/src/client.ts
+++ b/packages/api-client/src/client.ts
@@ -144,6 +144,9 @@ export class MeteorVoiceApiClient {
private async request(path: string, init: { method?: string; body?: unknown } = {}) {
const headers = new Headers(await this.resolveHeaders())
+ if (!headers.has('X-MeteorVoice-Client')) {
+ headers.set('X-MeteorVoice-Client', 'meteorvoice-api-client')
+ }
if (init.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
diff --git a/packages/shared/src/asr.ts b/packages/shared/src/asr.ts
index 80194fe..0387059 100644
--- a/packages/shared/src/asr.ts
+++ b/packages/shared/src/asr.ts
@@ -57,6 +57,19 @@ export type ASRSessionBootstrapResponse = {
endpointUrl?: string
headers?: Record
query?: Record
+ providerConfig?: {
+ appId?: string
+ domain?: string
+ language?: string
+ accent?: string
+ eosMs?: number
+ audioEncoding?: 'raw' | 'lame'
+ sampleRate?: 8000 | 16000
+ channels?: 1
+ bitDepth?: 16
+ frameIntervalMs?: number
+ frameSizeBytes?: number
+ }
config: ASRSessionConfig
}
@@ -74,6 +87,24 @@ export type ASRTranscriptEvent = {
error?: string
}
+export type ASRRuntimeAdapter = {
+ readonly provider: ASRProviderKey
+ start(config: ASRSessionConfig): Promise
+ stop(reason?: string): Promise
+ onEvent(listener: (event: ASRTranscriptEvent) => void): () => void
+}
+
+export type ASRRuntimeMetrics = {
+ provider: ASRProviderKey
+ sessionId?: string
+ bootstrapStartedAt?: number
+ bootstrapEndedAt?: number
+ speechStartedAt?: number
+ finalTranscriptAt?: number
+ transcriptChars?: number
+ error?: string
+}
+
export const asrProviderCapabilities: Record = {
native: {
modes: ['single_utterance'],
diff --git a/tests/api-client.test.ts b/tests/api-client.test.ts
index 8ad5bfd..f92eeb9 100644
--- a/tests/api-client.test.ts
+++ b/tests/api-client.test.ts
@@ -21,6 +21,7 @@ describe('MeteorVoiceApiClient', () => {
expect(calls[0].input).toBe('https://example.com/api/tts')
expect(calls[0].init?.method).toBe('POST')
expect(calls[0].init?.body).toBe(JSON.stringify({ text: 'Hello', accent: 'General American', speed: 1 }))
+ expect(new Headers(calls[0].init?.headers).get('X-MeteorVoice-Client')).toBe('meteorvoice-api-client')
})
it('throws a typed API error for non-2xx responses', async () => {
diff --git a/tests/api-guard.test.ts b/tests/api-guard.test.ts
new file mode 100644
index 0000000..51093f5
--- /dev/null
+++ b/tests/api-guard.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest'
+import { guardApiRequest } from '@/lib/server/http'
+
+describe('API guard', () => {
+ it('blocks configured abusive countries before expensive handlers run', () => {
+ const request = new Request('https://meteorvoice.test/api/chat', {
+ headers: {
+ 'x-vercel-ip-country': 'JP',
+ 'x-meteorvoice-client': 'meteorvoice-web',
+ },
+ })
+
+ expect(guardApiRequest(request, {
+ name: 'guard_country_test',
+ windowMs: 60000,
+ maxRequests: 10,
+ requireClientHeader: true,
+ })).toEqual({ error: 'Request blocked', status: 403 })
+ })
+
+ it('requires trusted client identification for high cost routes', () => {
+ const request = new Request('https://meteorvoice.test/api/tts', {
+ headers: { 'x-vercel-ip-country': 'US' },
+ })
+
+ expect(guardApiRequest(request, {
+ name: 'guard_header_test',
+ windowMs: 60000,
+ maxRequests: 10,
+ requireClientHeader: true,
+ })).toEqual({ error: 'Missing client identification', status: 403 })
+ })
+
+ it('rate limits repeated requests per route and IP', () => {
+ const makeRequest = () => new Request('https://meteorvoice.test/api/chat', {
+ headers: {
+ 'x-forwarded-for': '203.0.113.1',
+ 'x-vercel-ip-country': 'US',
+ 'x-meteorvoice-client': 'meteorvoice-web',
+ },
+ })
+
+ expect(guardApiRequest(makeRequest(), {
+ name: 'guard_rate_test',
+ windowMs: 60000,
+ maxRequests: 1,
+ requireClientHeader: true,
+ })).toBeNull()
+ expect(guardApiRequest(makeRequest(), {
+ name: 'guard_rate_test',
+ windowMs: 60000,
+ maxRequests: 1,
+ requireClientHeader: true,
+ })).toEqual({ error: 'Too many requests', status: 429 })
+ })
+})
diff --git a/tests/asr-provider.test.ts b/tests/asr-provider.test.ts
index bf26388..b9c5ad1 100644
--- a/tests/asr-provider.test.ts
+++ b/tests/asr-provider.test.ts
@@ -67,23 +67,37 @@ describe('ASR server registry', () => {
expect(getDefaultASRProvider()).toBe('native')
})
- it('accepts configured remote providers but keeps bootstrap explicit until signer exists', () => {
+ it('creates configured Xunfei bootstrap without returning raw API secrets', async () => {
process.env.ASR_PROVIDER = 'xunfei'
process.env.XUNFEI_ASR_APP_ID = 'app-id'
process.env.XUNFEI_ASR_API_KEY = 'api-key'
process.env.XUNFEI_ASR_API_SECRET = 'api-secret'
+ process.env.XUNFEI_ASR_PRODUCT = 'zh_iat'
expect(getDefaultASRProvider()).toBe('xunfei')
- expect(createASRSessionFromRequest({ provider: 'xunfei', mode: 'streaming' })).toMatchObject({
- error: 'ASR server bootstrap is not implemented for this provider yet',
- status: 501,
+ const result = await createASRSessionFromRequest({ provider: 'xunfei', mode: 'streaming', endpointSilenceMs: 1200 })
+
+ expect(result).toMatchObject({
+ provider: 'xunfei',
+ status: 'created',
+ transport: 'websocket',
+ providerConfig: {
+ appId: 'app-id',
+ domain: 'slm',
+ language: 'zh_cn',
+ accent: 'mandarin',
+ eosMs: 1200,
+ frameIntervalMs: 40,
+ frameSizeBytes: 1280,
+ },
})
+ expect(JSON.stringify(result)).not.toContain('api-secret')
})
- it('returns native unsupported bootstrap so mobile keeps using local STT for now', () => {
+ it('returns native unsupported bootstrap so mobile keeps using local STT for now', async () => {
vi.stubGlobal('crypto', { randomUUID: () => 'fixed-id' })
- expect(createASRSessionFromRequest({ provider: 'native' })).toMatchObject({
+ await expect(createASRSessionFromRequest({ provider: 'native' })).resolves.toMatchObject({
provider: 'native',
status: 'unsupported',
sessionId: 'asr_native_fixed-id',
From fabee3cfce9b2ee63eb6aaf415c81c8d9e9d7818 Mon Sep 17 00:00:00 2001
From: ConnorQi
Date: Sun, 31 May 2026 21:22:53 +0800
Subject: [PATCH 03/37] Require auth for protected API routes
---
apps/mobile/src/App.tsx | 6 ++++++
apps/web/app/api/asr/session/route.ts | 5 +++--
apps/web/app/api/chat/route.ts | 4 +++-
apps/web/app/api/preferences/route.ts | 6 +++++-
apps/web/app/api/semantic-endpoint/route.ts | 4 +++-
apps/web/app/api/tts/route.ts | 4 +++-
apps/web/lib/server/http.ts | 11 +++++++++++
docs/asr-provider-layer.md | 10 ++++++++++
8 files changed, 44 insertions(+), 6 deletions(-)
diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index 518e8ca..ec224e3 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -268,6 +268,12 @@ function AppInner() {
}, [])
function startSession() {
+ if (auth.state !== 'signed-in') {
+ setActiveTab('settings')
+ setStatus('login.signin')
+ return
+ }
+
logVoiceMetric('session_start', { scenario: scenario.key, accent: accent.key, provider: ttsProvider })
endpointRequestRef.current += 1
clearResumeListeningTimer()
diff --git a/apps/web/app/api/asr/session/route.ts b/apps/web/app/api/asr/session/route.ts
index 540515a..099d0de 100644
--- a/apps/web/app/api/asr/session/route.ts
+++ b/apps/web/app/api/asr/session/route.ts
@@ -1,5 +1,4 @@
-import { jsonApiResult, jsonServerError } from '@/lib/server/http'
-import { guardApiRequest } from '@/lib/server/http'
+import { guardApiRequest, jsonApiResult, jsonServerError, requireApiUser } from '@/lib/server/http'
import { createASRSessionFromRequest } from '@/lib/server/asr'
import type { ASRSessionBootstrapRequest } from '@meteorvoice/shared'
@@ -9,6 +8,8 @@ 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) {
diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts
index fffff18..bcf1bca 100644
--- a/apps/web/app/api/chat/route.ts
+++ b/apps/web/app/api/chat/route.ts
@@ -1,11 +1,13 @@
import type { ConversationMessage, ConversationContext } from '@/lib/providers/types'
import { generateCoachReply } from '@/lib/server/chat'
-import { guardApiRequest, 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 9eb5feb..f663780 100644
--- a/apps/web/app/api/preferences/route.ts
+++ b/apps/web/app/api/preferences/route.ts
@@ -1,10 +1,12 @@
-import { guardApiRequest, 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(request: Request) {
try {
const guard = guardApiRequest(request, { name: 'preferences_get', windowMs: 60_000, maxRequests: 120 })
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')
@@ -15,6 +17,8 @@ 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 86a6e42..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 { guardApiRequest, 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
@@ -30,6 +30,8 @@ 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 4119d2a..39d1630 100644
--- a/apps/web/app/api/tts/route.ts
+++ b/apps/web/app/api/tts/route.ts
@@ -1,4 +1,4 @@
-import { guardApiRequest, 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'
@@ -7,6 +7,8 @@ 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/lib/server/http.ts b/apps/web/lib/server/http.ts
index 5ff29be..99df2d0 100644
--- a/apps/web/lib/server/http.ts
+++ b/apps/web/lib/server/http.ts
@@ -1,4 +1,5 @@
import { NextResponse } from 'next/server'
+import { createClient } from '@/lib/supabase/server'
export type ApiErrorResult = {
error: string
@@ -72,6 +73,16 @@ export function guardApiRequest(request: Request, options: GuardOptions): ApiErr
return null
}
+export async function requireApiUser(): Promise {
+ const supabase = await createClient()
+ const { data: { user }, error } = await supabase.auth.getUser()
+ if (error || !user) {
+ return { error: 'Authentication required', status: 401 }
+ }
+
+ return null
+}
+
function getErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message) return error.message
if (typeof error === 'string' && error.trim()) return error
diff --git a/docs/asr-provider-layer.md b/docs/asr-provider-layer.md
index 3c71927..c916532 100644
--- a/docs/asr-provider-layer.md
+++ b/docs/asr-provider-layer.md
@@ -164,6 +164,11 @@ Server helper:
- `x-vercel-ip-country` against `API_GUARD_BLOCKED_COUNTRIES`, defaulting to `JP`.
- Optional trusted client header: `X-MeteorVoice-Client`.
- In-memory per route/IP rate bucket.
+- Authentication:
+ - High-cost routes call `requireApiUser()`.
+ - Web requests authenticate with Supabase cookies.
+ - Mobile requests authenticate with `Authorization: Bearer `.
+ - Unauthenticated requests return `401` before invoking AI, TTS, or ASR providers.
Trusted client header values:
@@ -181,6 +186,11 @@ Guarded routes:
This guard is a low-cost first line of defense. It is not a replacement for provider-side quotas, Vercel Firewall, or a durable distributed rate limiter. If abuse continues from many IPs, add a persistent limiter backed by Redis, Upstash, Supabase, or Vercel Firewall rules.
+Mobile behavior:
+
+- Starting a voice session now requires `auth.state === "signed-in"`.
+- If the mobile user is not signed in, session start sends the user to Settings login instead of calling protected APIs.
+
## Environment Variables
Provider discovery checks these variables:
From 362789928df07847ae20a454c382516407b57a35 Mon Sep 17 00:00:00 2001
From: ConnorQi
Date: Mon, 1 Jun 2026 00:25:06 +0800
Subject: [PATCH 04/37] Add ASR bootstrap diagnostics
---
apps/mobile/src/App.tsx | 57 ++++++++++++++++++++++
apps/mobile/src/screens/SettingsScreen.tsx | 7 ++-
docs/asr-provider-layer.md | 39 ++++++++++++++-
3 files changed, 101 insertions(+), 2 deletions(-)
diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index ec224e3..0c2b6f7 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -709,6 +709,62 @@ function AppInner() {
}
}
+ async function runASRDiagnostics() {
+ if (auth.state !== 'signed-in') {
+ setActiveTab('settings')
+ setSettingsMessage('Sign in before running ASR diagnostics.')
+ logVoiceMetric('asr_diagnostic_skipped', { reason: 'signed_out' })
+ return
+ }
+
+ const startedAt = Date.now()
+ logVoiceMetric('asr_diagnostic_start')
+ setSettingsMessage('Checking ASR providers...')
+
+ try {
+ const providers = await api.listASRProviders()
+ const selected = providers.providers.find(provider => provider.key === 'xunfei' && provider.enabled)
+ ?? providers.providers.find(provider => provider.key === providers.default_provider)
+ logVoiceMetric('asr_providers_loaded', {
+ defaultProvider: providers.default_provider,
+ enabled: providers.providers.filter(provider => provider.enabled).map(provider => provider.key).join(','),
+ })
+
+ if (!selected || selected.key === 'native') {
+ setSettingsMessage('ASR remote provider is not configured. Native STT remains active.')
+ logVoiceMetric('asr_diagnostic_done', {
+ provider: selected?.key ?? 'none',
+ remoteReady: false,
+ elapsedMs: Date.now() - startedAt,
+ })
+ return
+ }
+
+ const session = await api.createASRSession({
+ provider: selected.key,
+ mode: 'streaming',
+ languageMode: 'mixed_zh_en',
+ scenarioKey: scenario.key,
+ sessionId: snapshot.sessionId,
+ endpointSilenceMs: 900,
+ clientTraceId: `mobile-${Date.now()}`,
+ })
+ logVoiceMetric('asr_session_bootstrap_end', {
+ provider: session.provider,
+ status: session.status,
+ transport: session.transport,
+ elapsedMs: Date.now() - startedAt,
+ })
+ setSettingsMessage(`ASR ${session.provider} bootstrap ready (${session.transport}). Native STT still active.`)
+ } catch (error) {
+ const message = error instanceof MeteorVoiceApiError
+ ? `${error.message} (${error.status})`
+ : error instanceof Error ? error.message : 'ASR diagnostic failed'
+ logVoiceMetric('asr_diagnostic_error', { message, elapsedMs: Date.now() - startedAt })
+ setSettingsMessage(message)
+ }
+ }
+
async function deleteSession(id: string) {
setHistorySessions(prev => prev.map(s => s.id === id ? { ...s, status: 'deleted' } : s))
try {
@@ -1062,6 +1118,7 @@ function AppInner() {
message: voiceMetricsText || 'No voice metrics yet.',
})
}}
+ onRunASRDiagnostics={() => void runASRDiagnostics()}
/>
)
}
diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx
index 8be44a1..5787ee2 100644
--- a/apps/mobile/src/screens/SettingsScreen.tsx
+++ b/apps/mobile/src/screens/SettingsScreen.tsx
@@ -40,6 +40,7 @@ interface Props {
onSetApiBaseUrl: (v: string) => void
onClearVoiceMetrics: () => void
onShareVoiceMetrics: () => void
+ onRunASRDiagnostics: () => void
}
export function SettingsScreen({
@@ -49,7 +50,8 @@ export function SettingsScreen({
auth, email, password, authMode, apiBaseUrl, appVersion, voiceMetricsText,
onSetLocale, onSetTheme, onSaveProvider, onAdjustSpeed, onSavePracticePreferences,
onLoadPreferences, onSelectVoiceProfile,
- onSetEmail, onSetPassword, onSetAuthMode, onSubmitAuth, onSignOut, onSetApiBaseUrl, onClearVoiceMetrics, onShareVoiceMetrics,
+ onSetEmail, onSetPassword, onSetAuthMode, onSubmitAuth, onSignOut, onSetApiBaseUrl,
+ onClearVoiceMetrics, onShareVoiceMetrics, onRunASRDiagnostics,
}: Props) {
const { C, themeKey } = useTheme()
const speedFill = Math.max(0, Math.min(1, (ttsSpeed - 0.7) / 0.6))
@@ -296,6 +298,9 @@ export function SettingsScreen({
Voice diagnostics
+
+ ASR
+
Share
diff --git a/docs/asr-provider-layer.md b/docs/asr-provider-layer.md
index c916532..ef7fc5b 100644
--- a/docs/asr-provider-layer.md
+++ b/docs/asr-provider-layer.md
@@ -4,7 +4,7 @@ This document is the executable implementation guide for the shared ASR provider
## Current Status
-- Implemented: shared ASR types, provider capability registry, runtime adapter boundary, API client methods, server provider registry, `/api/asr/providers`, `/api/asr/session`, Xunfei `zh_iat` signed WebSocket bootstrap, API abuse guard, and contract tests.
+- Implemented: shared ASR types, provider capability registry, runtime adapter boundary, API client methods, server provider registry, `/api/asr/providers`, `/api/asr/session`, Xunfei `zh_iat` signed WebSocket bootstrap, API abuse guard, mobile ASR bootstrap diagnostics, and contract tests.
- Not implemented yet: mobile microphone PCM streaming to remote ASR, WebSocket relay, transcript event ingestion from remote ASR, and production mobile session switching.
- Production behavior remains unchanged: mobile live sessions still use `expo-speech-recognition` through `apps/mobile/src/nativeSpeech.ts`.
@@ -265,10 +265,46 @@ Current Xunfei implementation:
- 1280 bytes per frame
- max user utterance target: 60 seconds
+Mobile diagnostic implementation:
+
+- File: `apps/mobile/src/App.tsx`
+- Function: `runASRDiagnostics()`
+- UI entry: `apps/mobile/src/screens/SettingsScreen.tsx`, Voice diagnostics card, `ASR` button.
+- Behavior:
+ 1. Requires a signed-in mobile user because `/api/asr/*` is a protected high-cost API surface.
+ 2. Calls `api.listASRProviders()` and records `asr_diagnostic_start` plus `asr_providers_loaded`.
+ 3. Selects enabled `xunfei` first, otherwise falls back to the configured default provider.
+ 4. Calls `api.createASRSession()` with `mode: "streaming"` and `languageMode: "mixed_zh_en"`.
+ 5. Records `asr_session_bootstrap_end` or `asr_diagnostic_error`.
+ 6. Shows the result in Settings without changing production STT.
+
+This diagnostic validates server credentials, API auth, provider selection, signed URL creation, and mobile API reachability. It does not send microphone audio to Xunfei yet.
+
### P3: Client Adapter Interface
Goal: mobile/web session code can consume native or remote ASR through one adapter boundary.
+Current blocker:
+
+- `expo-speech-recognition` returns transcripts, not microphone audio frames.
+- `expo-audio` recording presets produce files such as AAC/M4A, not real-time 16 kHz 16-bit mono PCM frames.
+- Xunfei `zh_iat` streaming expects raw PCM frames over WebSocket, so the mobile client cannot simply route the existing Expo transcript stream into remote ASR.
+
+Implementation options:
+
+1. Native PCM capture adapter:
+ - Add an Expo Modules API native module under `apps/mobile/modules/` that owns microphone capture.
+ - Emit 16 kHz, 16-bit, mono PCM frames to JavaScript or stream them natively to the provider relay.
+ - Keep `nativeSpeech.ts` as the existing fallback until the remote path is proven on device.
+2. Server relay with file upload:
+ - Use Expo recording to create a file after user speech ends.
+ - Upload the file to the API.
+ - Transcode server-side to provider-required PCM and send to ASR.
+ - This is easier to prototype but adds full-utterance upload latency and needs server ffmpeg/transcoding support.
+3. Browser/Web adapter:
+ - Use browser audio APIs only for web runtime.
+ - Do not reuse it for iOS native unless the app runtime actually provides the same PCM access.
+
Implementation:
1. Use the shared runtime interface:
@@ -291,6 +327,7 @@ Done when:
- Current native path behaves the same as before.
- Remote provider can be enabled behind a config flag.
+- Remote path receives real user audio and returns `ASRTranscriptEvent` results.
- TTS playback still blocks user transcript handling.
- Each real utterance creates at most one AI turn.
From 53efb9517b1a2dceccb1ef89251f828ef0a86102 Mon Sep 17 00:00:00 2001
From: ConnorQi
Date: Mon, 1 Jun 2026 01:09:13 +0800
Subject: [PATCH 05/37] Document native PCM ASR plan
---
docs/asr-provider-layer.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/docs/asr-provider-layer.md b/docs/asr-provider-layer.md
index ef7fc5b..ced5627 100644
--- a/docs/asr-provider-layer.md
+++ b/docs/asr-provider-layer.md
@@ -305,6 +305,17 @@ Implementation options:
- Use browser audio APIs only for web runtime.
- Do not reuse it for iOS native unless the app runtime actually provides the same PCM access.
+Current P3 execution plan:
+
+1. Add a dedicated Expo Modules API module for iOS PCM capture instead of extending `expo-speech-recognition`.
+2. Keep the first PR path diagnostic-first:
+ - Start capture from Settings diagnostics.
+ - Emit frame count, byte count, duration, sample rate, channels, and errors.
+ - Do not switch production session STT until true-device PCM capture is proven.
+3. Keep `expo-speech-recognition` as the live-session fallback during this phase.
+4. Keep `expo-audio` for TTS playback; do not replace the playback path with the PCM module.
+5. After PCM capture is stable, connect the frame stream to the `/api/asr/session` bootstrap response and provider WebSocket.
+
Implementation:
1. Use the shared runtime interface:
From 594b489f5fc6d9113cae60e48e00100652a94449 Mon Sep 17 00:00:00 2001
From: ConnorQi
Date: Mon, 1 Jun 2026 01:26:00 +0800
Subject: [PATCH 06/37] Add native PCM ASR diagnostics
---
apps/mobile/ios/Podfile.lock | 6 +
.../voice-pcm-capture/expo-module.config.json | 6 +
.../ios/VoicePcmCapture.podspec | 26 ++
.../ios/VoicePcmCaptureModule.swift | 227 ++++++++++++++++++
.../modules/voice-pcm-capture/package.json | 8 +
apps/mobile/src/App.tsx | 224 +++++++++++++++++
apps/mobile/src/voicePcmCapture.ts | 99 ++++++++
docs/asr-provider-layer.md | 102 +++++++-
8 files changed, 685 insertions(+), 13 deletions(-)
create mode 100644 apps/mobile/modules/voice-pcm-capture/expo-module.config.json
create mode 100644 apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCapture.podspec
create mode 100644 apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCaptureModule.swift
create mode 100644 apps/mobile/modules/voice-pcm-capture/package.json
create mode 100644 apps/mobile/src/voicePcmCapture.ts
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..a13d311
--- /dev/null
+++ b/apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCaptureModule.swift
@@ -0,0 +1,227 @@
+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
+
+ 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
+
+ let session = AVAudioSession.sharedInstance()
+ try session.setCategory(
+ .playAndRecord,
+ mode: .voiceChat,
+ options: [.allowBluetoothHFP, .defaultToSpeaker]
+ )
+ try session.setActive(true)
+ try session.overrideOutputAudioPort(.speaker)
+
+ let nextEngine = AVAudioEngine()
+ let inputNode = nextEngine.inputNode
+ let inputFormat = 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
+ engine = nextEngine
+ isCapturing = true
+
+ 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)
+ }
+ }
+
+ do {
+ nextEngine.prepare()
+ try nextEngine.start()
+ } catch {
+ isCapturing = false
+ inputNode.removeTap(onBus: 0)
+ engine = nil
+ converter = nil
+ targetFormat = nil
+ throw error
+ }
+
+ 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)
+
+ let status = currentStatus(reason: reason)
+ sendState("stopped", message: reason)
+ return status
+ }
+
+ 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/src/App.tsx b/apps/mobile/src/App.tsx
index 0c2b6f7..269d9eb 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -12,6 +12,7 @@ import {
import {
createMeteorVoiceApiClient,
MeteorVoiceApiError,
+ type CreateASRSessionResponse,
type HistorySession,
type PreferencesResponse,
type SessionTurnDto,
@@ -60,6 +61,14 @@ import { useNativeSessionAudio } from './nativeAudio'
import { useNativeSpeech } from './nativeSpeech'
import { pullMobilePreferences, 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'
@@ -189,6 +198,7 @@ function AppInner() {
const pendingNativeTranscriptRef = useRef('')
const isCorrectionPlayingRef = useRef(false)
const resumeListeningTimerRef = useRef | null>(null)
+ const asrDiagnosticActiveRef = useRef(false)
const scenario = scenarios.find(item => item.key === selectedScenarioKey) ?? scenarios[0]
const accent = accentProfiles.find(item => item.key === selectedAccentKey) ?? accentProfiles[0]
@@ -755,6 +765,10 @@ function AppInner() {
transport: session.transport,
elapsedMs: Date.now() - startedAt,
})
+ if (session.provider === 'xunfei' && session.status === 'created' && session.transport === 'websocket') {
+ await runXunfeiASRStreamingDiagnostic(session, startedAt)
+ return
+ }
setSettingsMessage(`ASR ${session.provider} bootstrap ready (${session.transport}). Native STT still active.`)
} catch (error) {
const message = error instanceof MeteorVoiceApiError
@@ -765,6 +779,216 @@ function AppInner() {
}
}
+ async function runXunfeiASRStreamingDiagnostic(session: CreateASRSessionResponse, startedAt: number) {
+ if (asrDiagnosticActiveRef.current) {
+ setSettingsMessage('ASR diagnostic is already running.')
+ return
+ }
+ if (!session.endpointUrl) {
+ setSettingsMessage('ASR bootstrap returned no WebSocket URL.')
+ logVoiceMetric('asr_stream_skipped', { reason: 'missing_endpoint_url' })
+ return
+ }
+ if (!isPcmCaptureAvailable()) {
+ setSettingsMessage('ASR bootstrap ready, but native PCM capture is unavailable in this build.')
+ logVoiceMetric('asr_stream_skipped', { reason: 'pcm_module_unavailable' })
+ return
+ }
+
+ asrDiagnosticActiveRef.current = true
+ clearResumeListeningTimer()
+ await speechCancelListeningRef.current()
+ setSettingsMessage('ASR streaming diagnostic is listening for 8 seconds. Speak now.')
+ logVoiceMetric('asr_stream_start', {
+ provider: session.provider,
+ sampleRate: session.providerConfig?.sampleRate ?? 16000,
+ frameSizeBytes: session.providerConfig?.frameSizeBytes ?? 1280,
+ })
+
+ try {
+ const result = await runXunfeiDiagnosticWebSocket(session)
+ logVoiceMetric('asr_stream_done', {
+ elapsedMs: Date.now() - startedAt,
+ frameCount: result.frameCount,
+ totalBytes: result.totalBytes,
+ transcriptChars: result.transcript.length,
+ })
+ setSettingsMessage(result.transcript.trim()
+ ? `ASR diagnostic transcript: ${result.transcript.trim()}`
+ : `ASR diagnostic finished. Frames: ${result.frameCount}, bytes: ${result.totalBytes}.`)
+ } finally {
+ asrDiagnosticActiveRef.current = false
+ }
+ }
+
+ function runXunfeiDiagnosticWebSocket(session: CreateASRSessionResponse) {
+ return new Promise<{ frameCount: number; totalBytes: number; transcript: string }>((resolve, reject) => {
+ let socket: WebSocket | null = null
+ let frameSubscription: { remove: () => void } | null = null
+ let stateSubscription: { remove: () => void } | null = null
+ let stopTimer: ReturnType | null = null
+ let hardTimer: ReturnType | null = null
+ let settled = false
+ let firstFrame = true
+ let finalFrameSent = false
+ let frameCount = 0
+ let totalBytes = 0
+ let transcript = ''
+
+ const settle = (callback: () => void) => {
+ if (settled) return
+ settled = true
+ if (stopTimer) clearTimeout(stopTimer)
+ if (hardTimer) clearTimeout(hardTimer)
+ frameSubscription?.remove()
+ stateSubscription?.remove()
+ void stopPcmCapture('diagnostic_settled').catch(() => undefined)
+ if (socket && socket.readyState === WebSocket.OPEN) {
+ socket.close()
+ }
+ callback()
+ }
+
+ const sendAudioFrame = (status: 0 | 1 | 2, audioBase64: string) => {
+ if (!socket || socket.readyState !== WebSocket.OPEN || finalFrameSent) return
+ if (status === 2) finalFrameSent = true
+ socket.send(JSON.stringify(createXunfeiASRFrame(session, status, audioBase64)))
+ }
+
+ const finishAudio = () => {
+ if (finalFrameSent) return
+ sendAudioFrame(2, '')
+ void stopPcmCapture('diagnostic_timeout').catch(() => undefined)
+ }
+
+ try {
+ socket = new WebSocket(session.endpointUrl ?? '')
+ } catch (error) {
+ settle(() => reject(error))
+ return
+ }
+
+ stateSubscription = addPcmStateListener(event => {
+ logVoiceMetric('asr_pcm_state', {
+ state: event.state,
+ frameCount: event.frameCount,
+ totalBytes: event.totalBytes,
+ message: event.message,
+ })
+ })
+
+ frameSubscription = addPcmFrameListener((event: PcmCaptureFrameEvent) => {
+ if (!socket || socket.readyState !== WebSocket.OPEN || finalFrameSent) return
+ frameCount += 1
+ totalBytes += event.byteCount
+ sendAudioFrame(firstFrame ? 0 : 1, event.audioBase64)
+ firstFrame = false
+ if (frameCount === 1 || frameCount % 25 === 0) {
+ logVoiceMetric('asr_pcm_frame', {
+ frameCount,
+ totalBytes,
+ elapsedMs: event.elapsedMs,
+ })
+ }
+ })
+
+ socket.onopen = () => {
+ void startPcmCapture({
+ sampleRate: session.providerConfig?.sampleRate ?? 16000,
+ frameDurationMs: session.providerConfig?.frameIntervalMs ?? 40,
+ }).catch(error => {
+ settle(() => reject(error))
+ })
+ stopTimer = setTimeout(finishAudio, 8000)
+ hardTimer = setTimeout(() => {
+ settle(() => resolve({ frameCount, totalBytes, transcript }))
+ }, 12000)
+ }
+
+ socket.onmessage = event => {
+ const payload = parseJsonObject(event.data)
+ const code = typeof payload?.code === 'number' ? payload.code : 0
+ if (code !== 0) {
+ const message = typeof payload?.message === 'string' ? payload.message : `Xunfei ASR error ${code}`
+ logVoiceMetric('asr_stream_provider_error', { code, message })
+ settle(() => reject(new Error(message)))
+ return
+ }
+ const segment = extractXunfeiTranscript(payload)
+ if (segment) {
+ transcript += segment
+ logVoiceMetric('asr_partial', { chars: transcript.length })
+ }
+ const data = getObject(payload?.data)
+ const status = typeof data?.status === 'number' ? data.status : undefined
+ if (status === 2) {
+ settle(() => resolve({ frameCount, totalBytes, transcript }))
+ }
+ }
+
+ socket.onerror = () => {
+ settle(() => reject(new Error('Xunfei ASR WebSocket error')))
+ }
+
+ socket.onclose = () => {
+ settle(() => resolve({ frameCount, totalBytes, transcript }))
+ }
+ })
+ }
+
+ function createXunfeiASRFrame(session: CreateASRSessionResponse, status: 0 | 1 | 2, audioBase64: string) {
+ const providerConfig = session.providerConfig
+ const data = {
+ status,
+ format: `audio/L16;rate=${providerConfig?.sampleRate ?? 16000}`,
+ encoding: providerConfig?.audioEncoding ?? 'raw',
+ audio: audioBase64,
+ }
+ if (status !== 0) return { data }
+
+ return {
+ common: { app_id: providerConfig?.appId },
+ business: {
+ domain: providerConfig?.domain ?? 'slm',
+ language: providerConfig?.language ?? 'zh_cn',
+ accent: providerConfig?.accent ?? 'mandarin',
+ vad_eos: providerConfig?.eosMs ?? 900,
+ dwa: 'wpgs',
+ },
+ data,
+ }
+ }
+
+ 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 extractXunfeiTranscript(payload: Record | null) {
+ const data = getObject(payload?.data)
+ const result = getObject(data?.result)
+ const words = result?.ws
+ 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('')
+ }
+
async function deleteSession(id: string) {
setHistorySessions(prev => prev.map(s => s.id === id ? { ...s, status: 'deleted' } : s))
try {
diff --git a/apps/mobile/src/voicePcmCapture.ts b/apps/mobile/src/voicePcmCapture.ts
new file mode 100644
index 0000000..9445c8f
--- /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'
+ 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/docs/asr-provider-layer.md b/docs/asr-provider-layer.md
index ced5627..6affb9a 100644
--- a/docs/asr-provider-layer.md
+++ b/docs/asr-provider-layer.md
@@ -5,7 +5,8 @@ This document is the executable implementation guide for the shared ASR provider
## Current Status
- Implemented: shared ASR types, provider capability registry, runtime adapter boundary, API client methods, server provider registry, `/api/asr/providers`, `/api/asr/session`, Xunfei `zh_iat` signed WebSocket bootstrap, API abuse guard, mobile ASR bootstrap diagnostics, and contract tests.
-- Not implemented yet: mobile microphone PCM streaming to remote ASR, WebSocket relay, transcript event ingestion from remote ASR, and production mobile session switching.
+- Diagnostic-only: mobile can capture microphone PCM on iOS and stream it directly to Xunfei `zh_iat` from the Settings ASR diagnostic.
+- Not implemented yet: production mobile session switching, transcript event ingestion into the live turn state machine, WebSocket relay, and non-Xunfei remote adapters.
- Production behavior remains unchanged: mobile live sessions still use `expo-speech-recognition` through `apps/mobile/src/nativeSpeech.ts`.
## Target Outcome
@@ -265,7 +266,7 @@ Current Xunfei implementation:
- 1280 bytes per frame
- max user utterance target: 60 seconds
-Mobile diagnostic implementation:
+Mobile bootstrap diagnostic implementation:
- File: `apps/mobile/src/App.tsx`
- Function: `runASRDiagnostics()`
@@ -276,9 +277,10 @@ Mobile diagnostic implementation:
3. Selects enabled `xunfei` first, otherwise falls back to the configured default provider.
4. Calls `api.createASRSession()` with `mode: "streaming"` and `languageMode: "mixed_zh_en"`.
5. Records `asr_session_bootstrap_end` or `asr_diagnostic_error`.
- 6. Shows the result in Settings without changing production STT.
+ 6. If Xunfei returns a WebSocket bootstrap, runs the P3 streaming diagnostic described below.
+ 7. Shows the result in Settings without changing production STT.
-This diagnostic validates server credentials, API auth, provider selection, signed URL creation, and mobile API reachability. It does not send microphone audio to Xunfei yet.
+This diagnostic validates server credentials, API auth, provider selection, signed URL creation, mobile API reachability, direct provider WebSocket reachability, native PCM capture, and provider transcript parsing. It is intentionally not wired into live sessions yet.
### P3: Client Adapter Interface
@@ -305,16 +307,90 @@ Implementation options:
- Use browser audio APIs only for web runtime.
- Do not reuse it for iOS native unless the app runtime actually provides the same PCM access.
-Current P3 execution plan:
+Current P3 diagnostic implementation:
+
+1. A dedicated Expo Modules API module captures iOS microphone audio instead of extending `expo-speech-recognition`.
+2. Settings diagnostics call `/api/asr/session`, open the returned Xunfei signed WebSocket URL, and stream native PCM frames for an 8-second window.
+3. The diagnostic emits frame count, byte count, duration, sample rate, channels, provider partials, provider errors, and final transcript text into the existing Voice diagnostics log.
+4. `expo-speech-recognition` remains the production live-session STT path during this phase.
+5. `expo-audio` remains the TTS playback path; the PCM module does not replace playback.
+6. The remote transcript is displayed only in Settings. It is not fed into `acceptTranscriptTurn()` or the session state machine.
+
+Native PCM module:
+
+- Package: `apps/mobile/modules/voice-pcm-capture`
+- iOS module: `apps/mobile/modules/voice-pcm-capture/ios/VoicePcmCaptureModule.swift`
+- JS wrapper: `apps/mobile/src/voicePcmCapture.ts`
+- Module name: `VoicePcmCapture`
+- Events:
+ - `onPcmCaptureFrame`: emits `audioBase64`, `sequence`, `byteCount`, `sampleRate`, `channels`, `bitDepth`, `durationMs`, `elapsedMs`.
+ - `onPcmCaptureState`: emits `started`, `stopped`, or `error` plus capture status.
+- Functions:
+ - `start({ sampleRate?: number, frameDurationMs?: number }): Promise`
+ - `stop(reason?: string): Promise`
+ - `getStatus(): Promise`
+- Audio session:
+ - Category: `playAndRecord`
+ - Mode: `voiceChat`
+ - Options: `allowBluetoothHFP`, `defaultToSpeaker`
+ - Output override: speaker
+- Output format:
+ - 16 kHz
+ - 16-bit signed PCM
+ - mono
+ - 40 ms frame interval
+ - 1280 bytes per frame
+
+Direct Xunfei diagnostic:
+
+- File: `apps/mobile/src/App.tsx`
+- Functions:
+ - `runASRDiagnostics()`
+ - `runXunfeiASRStreamingDiagnostic(session, startedAt)`
+ - `runXunfeiDiagnosticWebSocket(session)`
+ - `createXunfeiASRFrame(session, status, audioBase64)`
+ - `extractXunfeiTranscript(payload)`
+- Runtime sequence:
+ 1. User taps Settings -> Voice diagnostics -> `ASR`.
+ 2. Mobile verifies the user is signed in.
+ 3. Mobile calls provider discovery and creates an Xunfei ASR session.
+ 4. Mobile cancels any current local STT listener before the diagnostic capture starts.
+ 5. Mobile opens `session.endpointUrl` with `WebSocket`.
+ 6. On socket open, mobile starts native PCM capture.
+ 7. First audio frame sends Xunfei `status: 0` with `common`, `business`, and `data`.
+ 8. Middle frames send Xunfei `status: 1` with `data`.
+ 9. After 8 seconds, mobile stops native capture and sends Xunfei `status: 2` with empty audio.
+ 10. Provider messages are parsed from `data.result.ws[].cw[].w`.
+ 11. The diagnostic resolves when Xunfei sends `data.status === 2`, the socket closes, or the hard 12-second timeout fires.
+ 12. Settings shows either `ASR diagnostic transcript: ...` or frame/byte counts when no transcript is returned.
+
+Diagnostics emitted:
+
+- `asr_diagnostic_start`
+- `asr_providers_loaded`
+- `asr_session_bootstrap_end`
+- `asr_stream_start`
+- `asr_pcm_state`
+- `asr_pcm_frame`
+- `asr_partial`
+- `asr_stream_provider_error`
+- `asr_stream_done`
+- `asr_diagnostic_error`
+
+Manual validation for this P3 PR:
+
+1. Install a fresh device build that includes the `VoicePcmCapture` native module.
+2. Sign in on mobile.
+3. Open Settings.
+4. Tap Voice diagnostics -> `ASR`.
+5. Speak one short mixed Chinese-English sentence during the 8-second listening window.
+6. Confirm Settings shows a transcript or meaningful frame/byte counts.
+7. Export/share Voice diagnostics logs.
+8. Confirm production `/session` still uses the old native STT path and does not consume the diagnostic transcript.
+
+Known limitation:
-1. Add a dedicated Expo Modules API module for iOS PCM capture instead of extending `expo-speech-recognition`.
-2. Keep the first PR path diagnostic-first:
- - Start capture from Settings diagnostics.
- - Emit frame count, byte count, duration, sample rate, channels, and errors.
- - Do not switch production session STT until true-device PCM capture is proven.
-3. Keep `expo-speech-recognition` as the live-session fallback during this phase.
-4. Keep `expo-audio` for TTS playback; do not replace the playback path with the PCM module.
-5. After PCM capture is stable, connect the frame stream to the `/api/asr/session` bootstrap response and provider WebSocket.
+- This direct-to-provider path exposes only a short-lived signed Xunfei WebSocket URL to the mobile client. It still does not expose API key or API secret, but all provider traffic goes from device to Xunfei. A future server relay can hide even the signed provider URL, centralize provider retries, and normalize transcript events before they reach the app.
Implementation:
From ac88658e0bf592f59b5e1a692b926eac487eaf14 Mon Sep 17 00:00:00 2001
From: ConnorQi
Date: Mon, 1 Jun 2026 21:37:54 +0800
Subject: [PATCH 07/37] Fix request errors and history loading
---
apps/mobile/src/App.tsx | 124 +++++++---
apps/mobile/src/mobileAuth.ts | 57 ++++-
apps/mobile/src/screens/HistoryScreen.tsx | 12 +-
apps/web/app/history/page.tsx | 19 +-
apps/web/app/settings/page.tsx | 19 +-
apps/web/components/VoiceSessionProvider.tsx | 18 +-
packages/api-client/src/errors.ts | 237 +++++++++++++++++++
packages/api-client/src/index.ts | 1 +
tests/api-client.test.ts | 42 +++-
9 files changed, 459 insertions(+), 70 deletions(-)
create mode 100644 packages/api-client/src/errors.ts
diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index 269d9eb..f09ff09 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -11,7 +11,7 @@ import {
} from 'react-native'
import {
createMeteorVoiceApiClient,
- MeteorVoiceApiError,
+ formatApiRequestError,
type CreateASRSessionResponse,
type HistorySession,
type PreferencesResponse,
@@ -199,6 +199,8 @@ function AppInner() {
const isCorrectionPlayingRef = useRef(false)
const resumeListeningTimerRef = useRef | null>(null)
const asrDiagnosticActiveRef = useRef(false)
+ const historyAutoLoadRef = useRef(false)
+ const settingsAutoLoadRef = useRef(false)
const scenario = scenarios.find(item => item.key === selectedScenarioKey) ?? scenarios[0]
const accent = accentProfiles.find(item => item.key === selectedAccentKey) ?? accentProfiles[0]
@@ -486,12 +488,12 @@ function AppInner() {
canListenOnRoute: canListenOnRouteRef.current,
})
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)
+ setStatus(requestError.displayMessage)
} finally {
setBusy(false)
}
@@ -700,7 +702,7 @@ function AppInner() {
setStatus('session.status.scenario_selected')
}
- async function loadHistory() {
+ const loadHistory = useCallback(async () => {
if (historyLoading) return
setHistoryLoading(true)
setHistoryError(null)
@@ -710,14 +712,21 @@ 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, historyLoading])
+
+ useEffect(() => {
+ if (activeTab !== 'history' || historyAutoLoadRef.current) return
+ historyAutoLoadRef.current = true
+ void loadHistory()
+ }, [activeTab, loadHistory])
async function runASRDiagnostics() {
if (auth.state !== 'signed-in') {
@@ -732,6 +741,19 @@ function AppInner() {
setSettingsMessage('Checking ASR providers...')
try {
+ const authReady = await auth.refreshSession()
+ const authHeaders = getAuthHeaders() as Record
+ const hasAuthorization = Boolean(authHeaders.Authorization)
+ logVoiceMetric('asr_auth_checked', {
+ refreshed: authReady,
+ hasAuthorization,
+ })
+ if (!authReady || !hasAuthorization) {
+ setSettingsMessage('Sign in again before running ASR diagnostics.')
+ logVoiceMetric('asr_auth_missing', { elapsedMs: Date.now() - startedAt })
+ return
+ }
+
const providers = await api.listASRProviders()
const selected = providers.providers.find(provider => provider.key === 'xunfei' && provider.enabled)
?? providers.providers.find(provider => provider.key === providers.default_provider)
@@ -771,11 +793,23 @@ function AppInner() {
}
setSettingsMessage(`ASR ${session.provider} bootstrap ready (${session.transport}). Native STT still active.`)
} catch (error) {
- const message = error instanceof MeteorVoiceApiError
- ? `${error.message} (${error.status})`
- : error instanceof Error ? error.message : 'ASR diagnostic failed'
- logVoiceMetric('asr_diagnostic_error', { message, elapsedMs: Date.now() - startedAt })
- setSettingsMessage(message)
+ const requestError = formatApiRequestError(error, {
+ context: 'asr_diagnostic',
+ presentation: 'inline',
+ })
+ if (requestError.kind === 'unauthorized') {
+ logVoiceMetric('asr_session_unauthorized', {
+ ...requestError.logData,
+ elapsedMs: Date.now() - startedAt,
+ })
+ setSettingsMessage(requestError.displayMessage)
+ return
+ }
+ logVoiceMetric('asr_diagnostic_error', {
+ ...requestError.logData,
+ elapsedMs: Date.now() - startedAt,
+ })
+ setSettingsMessage(requestError.displayMessage)
}
}
@@ -1008,14 +1042,15 @@ 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() {
+ const loadPreferences = useCallback(async () => {
if (settingsLoading) return
setSettingsLoading(true)
setSettingsMessage(null)
@@ -1034,14 +1069,21 @@ function AppInner() {
if (profile) setSelectedAccentKey(profile.accentKey)
setSettingsMessage(tr('session.status.preferences_loaded'))
} 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)
}
- }
+ }, [api, setLocale, settingsLoading, tr])
+
+ useEffect(() => {
+ if (activeTab !== 'settings' || auth.state !== 'signed-in' || settingsAutoLoadRef.current) return
+ settingsAutoLoadRef.current = true
+ void loadPreferences()
+ }, [activeTab, auth.state, loadPreferences])
async function saveProvider(provider: string) {
setTtsProvider(provider)
@@ -1069,10 +1111,11 @@ function AppInner() {
if (profile) setSelectedAccentKey(profile.accentKey)
setSettingsMessage(tr('session.status.preferences_saved'))
} 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)
}
@@ -1099,10 +1142,11 @@ function AppInner() {
if (result.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(result.selected_voice_profile_id)
setSettingsMessage(tr('session.status.practice_defaults_saved'))
} 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)
}
@@ -1142,8 +1186,12 @@ function AppInner() {
setTtsVoiceId(result.tts_voice_id)
setSelectedVoiceProfileId(result.selected_voice_profile_id)
if (result.voice_profiles) setVoiceProfiles(result.voice_profiles)
- } catch {
- // 静默失败
+ } catch (error) {
+ const requestError = formatApiRequestError(error, {
+ context: 'mobile_preferences_select_voice_profile',
+ presentation: 'silent',
+ })
+ logVoiceMetric('mobile_silent_request_error', requestError.logData)
}
}
diff --git a/apps/mobile/src/mobileAuth.ts b/apps/mobile/src/mobileAuth.ts
index 7f92b3c..bf4cc0f 100644
--- a/apps/mobile/src/mobileAuth.ts
+++ b/apps/mobile/src/mobileAuth.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createClient, type Session, type SupabaseClient, type User } from '@supabase/supabase-js'
import * as SecureStore from 'expo-secure-store'
@@ -27,6 +27,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
@@ -55,12 +56,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 +97,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')
@@ -111,18 +115,65 @@ export function useMobileAuth() {
if (!client) return
setState('loading')
await client.auth.signOut()
+ sessionRef.current = null
setSession(null)
setUser(null)
setState('signed-out')
}, [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])
+ sessionRef.current?.access_token ? { Authorization: `Bearer ${sessionRef.current.access_token}` } : {}
+ ), [])
return {
getAuthHeaders,
message,
+ refreshSession,
session,
signOut,
state,
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/web/app/history/page.tsx b/apps/web/app/history/page.tsx
index 61dc667..e09e570 100644
--- a/apps/web/app/history/page.tsx
+++ b/apps/web/app/history/page.tsx
@@ -5,6 +5,7 @@ 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'
interface HistorySession {
id: string
@@ -78,8 +79,7 @@ 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')
}, [])
// 首次加载
@@ -96,7 +96,10 @@ export default function HistoryPage() {
}
})
.catch(err => {
- setError(err instanceof Error ? err.message : t('history.load_error'))
+ setError(formatApiRequestError(err, {
+ context: 'web_history_list',
+ presentation: 'inline',
+ }).displayMessage)
})
.finally(() => setLoading(false))
}, [filterScenario, loadSessions, t])
@@ -129,11 +132,13 @@ export default function HistoryPage() {
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[] }
+ const data = await readApiJsonResponse<{ turns: TurnData[] }>(res, 'History turns request failed')
setTurns(data.turns ?? [])
- } catch {
- setTurnsError(t('history.load_error'))
+ } catch (err) {
+ setTurnsError(formatApiRequestError(err, {
+ context: 'web_history_turns',
+ presentation: 'inline',
+ }).displayMessage)
} finally {
setTurnsLoading(false)
}
diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx
index 4c54027..2008761 100644
--- a/apps/web/app/settings/page.tsx
+++ b/apps/web/app/settings/page.tsx
@@ -5,6 +5,7 @@ import { useLocale, useT } from '@/components/LanguageProvider'
import { persistPreference, persistTTSSpeedPreference, readTTSSpeedPreference, ttsSpeedOptions, writeTTSSpeedPreference, type TTSSpeed } from '@/lib/tts-speed'
import { writeTTSVoiceIdPreference } from '@/lib/tts-voice'
import type { Locale, 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'
@@ -49,6 +50,7 @@ export default function SettingsPage() {
const [voiceProfiles, setVoiceProfiles] = useState([])
const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState(null)
const [availableProviders, setAvailableProviders] = useState(['mock'])
+ const [settingsError, setSettingsError] = useState(null)
useEffect(() => {
async function loadTtsProvider() {
@@ -56,14 +58,15 @@ export default function SettingsPage() {
const res = await fetch('/api/preferences', {
headers: { 'X-MeteorVoice-Client': 'meteorvoice-web' },
})
- const data = await res.json() as {
+ const data = await readApiJsonResponse<{
tts_provider?: string
available_providers?: string[]
tts_speed?: number
tts_voice_id?: string | null
voice_profiles?: VoiceProfile[]
selected_voice_profile_id?: string | null
- }
+ }>(res, 'Preferences request failed')
+ setSettingsError(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)
@@ -80,7 +83,12 @@ export default function SettingsPage() {
setTtsSpeed(nextSpeed)
writeTTSSpeedPreference(nextSpeed)
}
- } catch {}
+ } catch (error) {
+ setSettingsError(formatApiRequestError(error, {
+ context: 'web_settings_preferences_load',
+ presentation: 'inline',
+ }).displayMessage)
+ }
}
void loadTtsProvider()
@@ -126,6 +134,11 @@ export default function SettingsPage() {
{t('settings.subtitle')}
+ {settingsError && (
+
+ {settingsError}
+
+ )}
diff --git a/apps/web/components/VoiceSessionProvider.tsx b/apps/web/components/VoiceSessionProvider.tsx
index 393f1b2..83bcc6a 100644
--- a/apps/web/components/VoiceSessionProvider.tsx
+++ b/apps/web/components/VoiceSessionProvider.tsx
@@ -34,6 +34,7 @@ import { browserSTTSupported, createBrowserSTT } from '@/lib/providers/browser-s
import { normalizeTTSSpeed, readTTSSpeedPreference, ttsSpeedChangeEvent, flushPendingPreferences, type TTSSpeed } from '@/lib/tts-speed'
import { readTTSVoiceIdPreference, ttsVoiceIdChangeEvent, writeTTSVoiceIdPreference } from '@/lib/tts-voice'
import { useT } from '@/components/LanguageProvider'
+import { formatApiRequestError, readApiJsonResponse } from '@meteorvoice/api-client'
const mockTTS = createMockTTS()
const activeSessionStorageKey = 'meteorvoice-active-session'
@@ -897,8 +898,7 @@ export default function VoiceSessionProvider({ children }: { children: ReactNode
headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-web' },
body: JSON.stringify({ text: speechText, accent: accentName, provider, speed: speedRouting.serverSpeed, voiceId: ttsVoiceIdRef.current }),
})
- const result = await res.json() as { audioUrl?: string; error?: string }
- if (!res.ok) throw new Error(result.error || `TTS request failed: ${res.status}`)
+ const result = await readApiJsonResponse<{ audioUrl?: string }>(res, 'TTS request failed')
if (!result.audioUrl) throw new Error('TTS response did not include audioUrl')
try {
@@ -1161,8 +1161,7 @@ export default function VoiceSessionProvider({ children }: { children: ReactNode
headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-web' },
body: JSON.stringify({ transcript: t, messages: ctx.messages, scenario: ctx.scenario }),
})
- if (!res.ok) throw new Error('Semantic check failed')
- const data = await res.json() as { judgment: 'done' | 'thinking' }
+ const data = await readApiJsonResponse<{ judgment: 'done' | 'thinking' }>(res, 'Semantic check failed')
return data.judgment
},
})
@@ -1212,11 +1211,14 @@ export default function VoiceSessionProvider({ children }: { children: ReactNode
},
}),
})
- if (!res.ok) throw new Error(`Chat request failed: ${res.status}`)
- response = await res.json() as ConversationResponse
- } catch {
+ response = await readApiJsonResponse(res, 'Chat request failed')
+ } catch (error) {
if (!isCurrentTurn()) return
- setStatusText(canListenOnRouteRef.current ? tr('session.tap_mic') : tr('session.paused'))
+ const requestError = formatApiRequestError(error, {
+ context: 'web_session_chat',
+ presentation: 'banner',
+ })
+ setStatusText(canListenOnRouteRef.current ? requestError.displayMessage : tr('session.paused'))
updateSnapshot(current => recoverSessionError({
snapshot: current,
reason: 'coach_reply_failed',
diff --git a/packages/api-client/src/errors.ts b/packages/api-client/src/errors.ts
new file mode 100644
index 0000000..25b0a0a
--- /dev/null
+++ b/packages/api-client/src/errors.ts
@@ -0,0 +1,237 @@
+import { MeteorVoiceApiError } from './client'
+
+export type ApiRequestErrorKind =
+ | 'unauthorized'
+ | 'forbidden'
+ | 'rate_limited'
+ | 'server'
+ | 'network'
+ | 'unknown'
+
+export type ApiRequestErrorPresentation =
+ | 'inline'
+ | 'toast'
+ | 'alert'
+ | 'banner'
+ | 'sheet'
+ | 'blocking'
+ | 'silent'
+export type ApiRequestErrorSeverity = 'info' | 'warning' | 'error'
+export type ApiRequestErrorAction = 'sign_in' | 'retry' | 'none'
+
+export type ApiRequestErrorDetails = {
+ kind: ApiRequestErrorKind
+ title: string
+ displayMessage: string
+ presentation: ApiRequestErrorPresentation
+ severity: ApiRequestErrorSeverity
+ action: ApiRequestErrorAction
+ actionLabel?: string
+ autoDismissMs?: number
+ blocksInteraction: boolean
+ dismissible: boolean
+ shouldDisplay: boolean
+ logData: Record
+ status?: number
+}
+
+export type FormatApiRequestErrorOptions = {
+ context: string
+ presentation?: ApiRequestErrorPresentation
+}
+
+export async function readApiJsonResponse(response: Response, fallbackMessage = 'Request failed'): Promise {
+ const body = await readResponseJson(response)
+ if (!response.ok) {
+ const message = isApiErrorBody(body) ? body.error : `${fallbackMessage}: ${response.status}`
+ throw new MeteorVoiceApiError(message, response.status, body)
+ }
+ return body as T
+}
+
+export function formatApiRequestError(
+ error: unknown,
+ options: string | FormatApiRequestErrorOptions,
+): ApiRequestErrorDetails {
+ const context = typeof options === 'string' ? options : options.context
+ const presentation = typeof options === 'string' ? 'inline' : options.presentation ?? 'inline'
+ const presentationConfig = getPresentationConfig(presentation)
+
+ if (error instanceof MeteorVoiceApiError) {
+ const kind = getApiErrorKind(error.status)
+ const display = getApiDisplay(kind)
+ return {
+ kind,
+ status: error.status,
+ title: display.title,
+ displayMessage: display.message,
+ presentation,
+ severity: display.severity,
+ action: display.action,
+ actionLabel: display.actionLabel,
+ ...presentationConfig,
+ logData: {
+ context,
+ kind,
+ status: error.status,
+ message: error.message,
+ },
+ }
+ }
+
+ if (error instanceof TypeError) {
+ return {
+ kind: 'network',
+ title: 'Network unavailable',
+ displayMessage: 'Network request failed. Check the connection and try again.',
+ presentation,
+ severity: 'warning',
+ action: 'retry',
+ actionLabel: 'Try again',
+ ...presentationConfig,
+ logData: {
+ context,
+ kind: 'network',
+ message: error.message,
+ },
+ }
+ }
+
+ const message = error instanceof Error ? error.message : 'Request failed'
+ return {
+ kind: 'unknown',
+ title: 'Request failed',
+ displayMessage: message,
+ presentation,
+ severity: 'error',
+ action: 'none',
+ ...presentationConfig,
+ logData: {
+ context,
+ kind: 'unknown',
+ message,
+ },
+ }
+}
+
+async function readResponseJson(response: Response) {
+ const text = await response.text()
+ if (!text) return null
+ try {
+ return JSON.parse(text) as unknown
+ } catch {
+ return text
+ }
+}
+
+function isApiErrorBody(body: unknown): body is { error: string } {
+ return Boolean(
+ body &&
+ typeof body === 'object' &&
+ 'error' in body &&
+ typeof (body as { error?: unknown }).error === 'string',
+ )
+}
+
+function getApiErrorKind(status: number): ApiRequestErrorKind {
+ if (status === 401) return 'unauthorized'
+ if (status === 403) return 'forbidden'
+ if (status === 429) return 'rate_limited'
+ if (status >= 500) return 'server'
+ return 'unknown'
+}
+
+function getApiDisplay(kind: ApiRequestErrorKind) {
+ if (kind === 'unauthorized') {
+ return {
+ title: 'Sign in required',
+ message: 'Sign in again and try this request.',
+ severity: 'warning' as const,
+ action: 'sign_in' as const,
+ actionLabel: 'Sign in',
+ }
+ }
+ if (kind === 'forbidden') {
+ return {
+ title: 'Request blocked',
+ message: 'This request was blocked. Try again later.',
+ severity: 'warning' as const,
+ action: 'none' as const,
+ }
+ }
+ if (kind === 'rate_limited') {
+ return {
+ title: 'Too many requests',
+ message: 'Wait a moment and try again.',
+ severity: 'warning' as const,
+ action: 'retry' as const,
+ actionLabel: 'Try again',
+ }
+ }
+ if (kind === 'server') {
+ return {
+ title: 'Service unavailable',
+ message: 'Service is temporarily unavailable. Try again later.',
+ severity: 'error' as const,
+ action: 'retry' as const,
+ actionLabel: 'Try again',
+ }
+ }
+ return {
+ title: 'Request failed',
+ message: 'Request failed. Try again later.',
+ severity: 'error' as const,
+ action: 'none' as const,
+ }
+}
+
+function getPresentationConfig(presentation: ApiRequestErrorPresentation) {
+ if (presentation === 'toast') {
+ return {
+ autoDismissMs: 4000,
+ blocksInteraction: false,
+ dismissible: true,
+ shouldDisplay: true,
+ }
+ }
+ if (presentation === 'alert') {
+ return {
+ blocksInteraction: true,
+ dismissible: true,
+ shouldDisplay: true,
+ }
+ }
+ if (presentation === 'banner') {
+ return {
+ blocksInteraction: false,
+ dismissible: true,
+ shouldDisplay: true,
+ }
+ }
+ if (presentation === 'sheet') {
+ return {
+ blocksInteraction: true,
+ dismissible: true,
+ shouldDisplay: true,
+ }
+ }
+ if (presentation === 'blocking') {
+ return {
+ blocksInteraction: true,
+ dismissible: false,
+ shouldDisplay: true,
+ }
+ }
+ if (presentation === 'silent') {
+ return {
+ blocksInteraction: false,
+ dismissible: false,
+ shouldDisplay: false,
+ }
+ }
+ return {
+ blocksInteraction: false,
+ dismissible: false,
+ shouldDisplay: true,
+ }
+}
diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts
index edfed4a..994ecd3 100644
--- a/packages/api-client/src/index.ts
+++ b/packages/api-client/src/index.ts
@@ -1,2 +1,3 @@
export * from './client'
+export * from './errors'
export * from './types'
diff --git a/tests/api-client.test.ts b/tests/api-client.test.ts
index f92eeb9..f5ce8ae 100644
--- a/tests/api-client.test.ts
+++ b/tests/api-client.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { createMeteorVoiceApiClient, MeteorVoiceApiError } from '@meteorvoice/api-client'
+import { createMeteorVoiceApiClient, formatApiRequestError, MeteorVoiceApiError, readApiJsonResponse } from '@meteorvoice/api-client'
describe('MeteorVoiceApiClient', () => {
it('joins baseUrl and sends JSON requests', async () => {
@@ -36,6 +36,46 @@ describe('MeteorVoiceApiClient', () => {
} satisfies Partial)
})
+ it('formats API errors for shared web and mobile presentation', () => {
+ const error = new MeteorVoiceApiError('Authentication required', 401, { error: 'Authentication required' })
+
+ const result = formatApiRequestError(error, {
+ context: 'asr_diagnostic',
+ presentation: 'toast',
+ })
+
+ expect(result).toMatchObject({
+ kind: 'unauthorized',
+ status: 401,
+ title: 'Sign in required',
+ displayMessage: 'Sign in again and try this request.',
+ presentation: 'toast',
+ severity: 'warning',
+ action: 'sign_in',
+ actionLabel: 'Sign in',
+ autoDismissMs: 4000,
+ blocksInteraction: false,
+ dismissible: true,
+ shouldDisplay: true,
+ logData: {
+ context: 'asr_diagnostic',
+ kind: 'unauthorized',
+ status: 401,
+ message: 'Authentication required',
+ },
+ })
+ })
+
+ it('converts fetch responses into typed API errors', async () => {
+ const response = new Response(JSON.stringify({ error: 'Too many requests' }), { status: 429 })
+
+ await expect(readApiJsonResponse(response, 'History request failed')).rejects.toMatchObject({
+ name: 'MeteorVoiceApiError',
+ message: 'Too many requests',
+ status: 429,
+ } satisfies Partial)
+ })
+
it('supports async headers for mobile auth sessions', async () => {
const calls: { input: RequestInfo | URL; init?: RequestInit }[] = []
const client = createMeteorVoiceApiClient({
From b8cf46235f0af63c1cc3a7cf6355b7ec5da9526a Mon Sep 17 00:00:00 2001
From: Connor
Date: Tue, 2 Jun 2026 00:38:50 +0800
Subject: [PATCH 08/37] [Mobile] Add ASR P4 evaluation diagnostics (#227)
* Add ASR P4 evaluation diagnostics
* Fix ASR P4 streaming diagnostics
---
apps/mobile/src/App.tsx | 374 ++++++++++++++++++---
apps/mobile/src/screens/SettingsScreen.tsx | 28 +-
apps/web/lib/providers/xunfei-voices.ts | 4 +-
docs/asr-provider-layer.md | 52 ++-
docs/tts-integration.md | 4 +-
packages/shared/src/i18n.ts | 6 +
tests/xunfei-tts.test.ts | 2 +-
7 files changed, 405 insertions(+), 65 deletions(-)
diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index f09ff09..77693c2 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -79,12 +79,24 @@ const defaultApiBaseUrl = getDefaultApiBaseUrl()
const appVersion = getDisplayAppVersion()
const apiBaseUrlStorageKey = 'api_base_url'
type Tab = 'session' | 'home' | 'history' | 'settings'
+type ApiBaseUrlSource = 'default' | 'user'
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',
@@ -123,6 +135,97 @@ 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))
+ })
+}
+
export default function App() {
return (
@@ -135,6 +238,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)
@@ -147,7 +251,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) => {
@@ -189,6 +299,7 @@ function AppInner() {
const speechStartListeningRef = useRef<(lang?: string) => Promise>(() => Promise.resolve(false))
const speechCancelListeningRef = useRef<() => void | Promise>(() => undefined)
const endpointRequestRef = useRef(0)
+ const turnRequestRef = useRef(0)
const sessionActiveRef = useRef(false)
const canListenOnRouteRef = useRef(true)
const playbackActiveRef = useRef(false)
@@ -249,6 +360,7 @@ function AppInner() {
.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()
@@ -273,12 +385,20 @@ 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)
}, [])
+ const resetApiBaseUrl = useCallback(() => {
+ setApiBaseUrl(defaultApiBaseUrl)
+ setApiBaseUrlSource('default')
+ void SecureStore.deleteItemAsync(apiBaseUrlStorageKey)
+ }, [])
+
function startSession() {
if (auth.state !== 'signed-in') {
setActiveTab('settings')
@@ -421,12 +541,13 @@ function AppInner() {
clearResumeListeningTimer()
await speechCancelListeningRef.current()
setBusy(true)
+ const turnRequestId = ++turnRequestRef.current
try {
setStatus('session.status.requesting_reply')
nextSnapshot = requestCoachReply(nextSnapshot)
setSnapshot(nextSnapshot)
- const coachReply = await api.generateCoachReply({
+ const coachReply = await withTimeout(api.generateCoachReply({
messages: nextMessages,
context: {
scenario: { name: scenario.name, description: scenario.description },
@@ -434,7 +555,11 @@ function AppInner() {
sessionId: nextSnapshot.sessionId,
turnNumber: nextMessages.filter(message => message.role === 'user').length,
},
- })
+ }), 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,
@@ -453,9 +578,16 @@ function AppInner() {
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),
@@ -474,6 +606,9 @@ function AppInner() {
} else {
playbackActiveRef.current = false
setStatus('session.status.reply_without_audio')
+ if (sessionActiveRef.current && canListenOnRouteRef.current) {
+ scheduleResumeListening(500)
+ }
}
const completedTurn = completeCoachPlayback({
snapshot: nextSnapshot,
@@ -494,12 +629,15 @@ function AppInner() {
})
logVoiceMetric('mobile_session_request_error', requestError.logData)
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,
+ logVoiceMetric, messages, scenario.description, scenario.name, scheduleResumeListening, snapshot, synthesizeCoachSpeech,
])
const handleNativeFinalTranscript = useCallback(async (finalTranscript: string) => {
@@ -649,9 +787,10 @@ function AppInner() {
}, [busy, clearResumeListeningTimer])
async function endSession() {
- if (!canEndSession({ activeSession: isSessionActive, workflowState: snapshot.state }) || busy) return
+ if (!canEndSession({ activeSession: isSessionActive, workflowState: snapshot.state })) return
// 立即结束 session,不等 API
+ turnRequestRef.current += 1
sessionActiveRef.current = false
canListenOnRouteRef.current = false
playbackActiveRef.current = false
@@ -664,8 +803,8 @@ function AppInner() {
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({
@@ -685,8 +824,6 @@ function AppInner() {
}).catch(() => undefined)
} catch {
// summary 失败不影响 session 已结束的状态
- } finally {
- setBusy(false)
}
}
@@ -729,6 +866,12 @@ function AppInner() {
}, [activeTab, loadHistory])
async function runASRDiagnostics() {
+ if (asrDiagnosticActiveRef.current) {
+ setSettingsMessage('ASR diagnostic is already running.')
+ logVoiceMetric('asr_diagnostic_skipped', { reason: 'already_running' })
+ return
+ }
+
if (auth.state !== 'signed-in') {
setActiveTab('settings')
setSettingsMessage('Sign in before running ASR diagnostics.')
@@ -736,6 +879,7 @@ function AppInner() {
return
}
+ asrDiagnosticActiveRef.current = true
const startedAt = Date.now()
logVoiceMetric('asr_diagnostic_start')
setSettingsMessage('Checking ASR providers...')
@@ -772,6 +916,10 @@ function AppInner() {
return
}
+ logVoiceMetric('asr_session_bootstrap_start', {
+ provider: selected.key,
+ elapsedMs: Date.now() - startedAt,
+ })
const session = await api.createASRSession({
provider: selected.key,
mode: 'streaming',
@@ -810,14 +958,12 @@ function AppInner() {
elapsedMs: Date.now() - startedAt,
})
setSettingsMessage(requestError.displayMessage)
+ } finally {
+ asrDiagnosticActiveRef.current = false
}
}
async function runXunfeiASRStreamingDiagnostic(session: CreateASRSessionResponse, startedAt: number) {
- if (asrDiagnosticActiveRef.current) {
- setSettingsMessage('ASR diagnostic is already running.')
- return
- }
if (!session.endpointUrl) {
setSettingsMessage('ASR bootstrap returned no WebSocket URL.')
logVoiceMetric('asr_stream_skipped', { reason: 'missing_endpoint_url' })
@@ -829,7 +975,6 @@ function AppInner() {
return
}
- asrDiagnosticActiveRef.current = true
clearResumeListeningTimer()
await speechCancelListeningRef.current()
setSettingsMessage('ASR streaming diagnostic is listening for 8 seconds. Speak now.')
@@ -843,6 +988,8 @@ function AppInner() {
const result = await runXunfeiDiagnosticWebSocket(session)
logVoiceMetric('asr_stream_done', {
elapsedMs: Date.now() - startedAt,
+ streamElapsedMs: result.streamElapsedMs,
+ firstPartialElapsedMs: result.firstPartialElapsedMs,
frameCount: result.frameCount,
totalBytes: result.totalBytes,
transcriptChars: result.transcript.length,
@@ -851,12 +998,12 @@ function AppInner() {
? `ASR diagnostic transcript: ${result.transcript.trim()}`
: `ASR diagnostic finished. Frames: ${result.frameCount}, bytes: ${result.totalBytes}.`)
} finally {
- asrDiagnosticActiveRef.current = false
+ // runASRDiagnostics owns the active flag so bootstrap failures and stream runs share one lock.
}
}
function runXunfeiDiagnosticWebSocket(session: CreateASRSessionResponse) {
- return new Promise<{ frameCount: number; totalBytes: number; transcript: string }>((resolve, reject) => {
+ return new Promise<{ frameCount: number; totalBytes: number; transcript: string; streamElapsedMs: number; firstPartialElapsedMs: number | null }>((resolve, reject) => {
let socket: WebSocket | null = null
let frameSubscription: { remove: () => void } | null = null
let stateSubscription: { remove: () => void } | null = null
@@ -865,9 +1012,18 @@ function AppInner() {
let settled = false
let firstFrame = true
let finalFrameSent = false
+ let audioSequence = 0
let frameCount = 0
let totalBytes = 0
let transcript = ''
+ const transcriptSegments: string[] = []
+ const streamStartedAt = Date.now()
+ let firstPartialElapsedMs: number | null = null
+ let finalReceived = false
+ let providerMessageCount = 0
+ let lastProviderCode: number | null = null
+ let lastProviderStatus: number | null = null
+ let lastProviderMessage: string | null = null
const settle = (callback: () => void) => {
if (settled) return
@@ -875,8 +1031,9 @@ function AppInner() {
if (stopTimer) clearTimeout(stopTimer)
if (hardTimer) clearTimeout(hardTimer)
frameSubscription?.remove()
- stateSubscription?.remove()
- void stopPcmCapture('diagnostic_settled').catch(() => undefined)
+ void stopPcmCapture('diagnostic_settled')
+ .catch(() => undefined)
+ .finally(() => stateSubscription?.remove())
if (socket && socket.readyState === WebSocket.OPEN) {
socket.close()
}
@@ -886,7 +1043,8 @@ function AppInner() {
const sendAudioFrame = (status: 0 | 1 | 2, audioBase64: string) => {
if (!socket || socket.readyState !== WebSocket.OPEN || finalFrameSent) return
if (status === 2) finalFrameSent = true
- socket.send(JSON.stringify(createXunfeiASRFrame(session, status, audioBase64)))
+ audioSequence += 1
+ socket.send(JSON.stringify(createXunfeiASRFrame(session, status, audioBase64, audioSequence)))
}
const finishAudio = () => {
@@ -935,28 +1093,68 @@ function AppInner() {
})
stopTimer = setTimeout(finishAudio, 8000)
hardTimer = setTimeout(() => {
- settle(() => resolve({ frameCount, totalBytes, transcript }))
+ settle(() => resolve({ frameCount, totalBytes, transcript, streamElapsedMs: Date.now() - streamStartedAt, firstPartialElapsedMs }))
}, 12000)
}
socket.onmessage = event => {
+ providerMessageCount += 1
const payload = parseJsonObject(event.data)
- const code = typeof payload?.code === 'number' ? payload.code : 0
+ const header = getObject(payload?.header)
+ const code = typeof header?.code === 'number'
+ ? header.code
+ : typeof payload?.code === 'number'
+ ? payload.code
+ : 0
+ lastProviderCode = code
+ lastProviderMessage = typeof header?.message === 'string'
+ ? header.message
+ : typeof payload?.message === 'string'
+ ? payload.message
+ : null
if (code !== 0) {
- const message = typeof payload?.message === 'string' ? payload.message : `Xunfei ASR error ${code}`
+ const message = lastProviderMessage ?? `Xunfei ASR error ${code}`
logVoiceMetric('asr_stream_provider_error', { code, message })
settle(() => reject(new Error(message)))
return
}
- const segment = extractXunfeiTranscript(payload)
- if (segment) {
- transcript += segment
+ 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('')
+ if (firstPartialElapsedMs == null) {
+ firstPartialElapsedMs = Date.now() - streamStartedAt
+ logVoiceMetric('asr_first_partial', {
+ elapsedMs: firstPartialElapsedMs,
+ chars: transcript.length,
+ })
+ }
logVoiceMetric('asr_partial', { chars: transcript.length })
}
const data = getObject(payload?.data)
- const status = typeof data?.status === 'number' ? data.status : undefined
+ const status = typeof header?.status === 'number'
+ ? header.status
+ : typeof data?.status === 'number'
+ ? data.status
+ : undefined
+ lastProviderStatus = status ?? null
if (status === 2) {
- settle(() => resolve({ frameCount, totalBytes, transcript }))
+ finalReceived = true
+ logVoiceMetric('asr_final', {
+ elapsedMs: Date.now() - streamStartedAt,
+ chars: transcript.length,
+ })
+ settle(() => resolve({ frameCount, totalBytes, transcript, streamElapsedMs: Date.now() - streamStartedAt, firstPartialElapsedMs }))
}
}
@@ -964,32 +1162,68 @@ function AppInner() {
settle(() => reject(new Error('Xunfei ASR WebSocket error')))
}
- socket.onclose = () => {
- settle(() => resolve({ frameCount, totalBytes, transcript }))
+ socket.onclose = event => {
+ const closeData = {
+ code: typeof event?.code === 'number' ? event.code : null,
+ reason: typeof event?.reason === 'string' ? event.reason : '',
+ wasClean: Boolean(event?.wasClean),
+ finalReceived,
+ finalFrameSent,
+ frameCount,
+ totalBytes,
+ providerMessageCount,
+ lastProviderCode,
+ lastProviderStatus,
+ lastProviderMessage,
+ }
+ logVoiceMetric('asr_stream_socket_close', closeData)
+ if (!finalReceived && frameCount < 10) {
+ logVoiceMetric('asr_stream_closed_early', closeData)
+ }
+ settle(() => resolve({ frameCount, totalBytes, transcript, streamElapsedMs: Date.now() - streamStartedAt, firstPartialElapsedMs }))
}
})
}
- function createXunfeiASRFrame(session: CreateASRSessionResponse, status: 0 | 1 | 2, audioBase64: string) {
+ function createXunfeiASRFrame(session: CreateASRSessionResponse, status: 0 | 1 | 2, audioBase64: string, sequence: number) {
const providerConfig = session.providerConfig
- const data = {
+ const header = {
+ app_id: providerConfig?.appId,
status,
- format: `audio/L16;rate=${providerConfig?.sampleRate ?? 16000}`,
+ }
+ 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 { data }
+ if (status !== 0) {
+ return {
+ header,
+ payload: { audio },
+ }
+ }
return {
- common: { app_id: providerConfig?.appId },
- business: {
- domain: providerConfig?.domain ?? 'slm',
- language: providerConfig?.language ?? 'zh_cn',
- accent: providerConfig?.accent ?? 'mandarin',
- vad_eos: providerConfig?.eosMs ?? 900,
- dwa: 'wpgs',
+ 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',
+ },
+ },
},
- data,
+ payload: { audio },
}
}
@@ -1007,10 +1241,38 @@ function AppInner() {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null
}
- function extractXunfeiTranscript(payload: 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 words = result?.ws
+ 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)
@@ -1023,6 +1285,20 @@ function AppInner() {
}).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 ''
+ }
+ }
+
async function deleteSession(id: string) {
setHistorySessions(prev => prev.map(s => s.id === id ? { ...s, status: 'deleted' } : s))
try {
@@ -1368,8 +1644,11 @@ function AppInner() {
password={password}
authMode={authMode}
apiBaseUrl={apiBaseUrl}
+ apiBaseUrlSource={apiBaseUrlSource}
+ defaultApiBaseUrl={defaultApiBaseUrl}
appVersion={appVersion}
voiceMetricsText={voiceMetricsText}
+ asrEvaluationText={asrEvaluationText}
onSetLocale={l => setLocale(l as Locale)}
onSetTheme={setTheme}
onSaveProvider={p => void saveProvider(p)}
@@ -1383,6 +1662,7 @@ function AppInner() {
onSubmitAuth={() => void submitAuth()}
onSignOut={() => void auth.signOut()}
onSetApiBaseUrl={updateApiBaseUrl}
+ onResetApiBaseUrl={resetApiBaseUrl}
onClearVoiceMetrics={() => setVoiceMetrics([])}
onShareVoiceMetrics={() => {
void Share.share({
@@ -1390,6 +1670,12 @@ function AppInner() {
message: voiceMetricsText || 'No voice metrics yet.',
})
}}
+ onShareASREvaluation={() => {
+ void Share.share({
+ title: 'MeteorVoice ASR P4 evaluation',
+ message: asrEvaluationText,
+ })
+ }}
onRunASRDiagnostics={() => void runASRDiagnostics()}
/>
)
diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx
index 5787ee2..ead266a 100644
--- a/apps/mobile/src/screens/SettingsScreen.tsx
+++ b/apps/mobile/src/screens/SettingsScreen.tsx
@@ -23,8 +23,11 @@ interface Props {
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
@@ -38,8 +41,10 @@ interface Props {
onSubmitAuth: () => void
onSignOut: () => void
onSetApiBaseUrl: (v: string) => void
+ onResetApiBaseUrl: () => void
onClearVoiceMetrics: () => void
onShareVoiceMetrics: () => void
+ onShareASREvaluation: () => void
onRunASRDiagnostics: () => void
}
@@ -47,11 +52,11 @@ export function SettingsScreen({
tr, locale, ttsProvider, availableProviders, ttsSpeed,
ttsVoiceId, voiceProfiles, selectedVoiceProfileId, xunfeiVoices,
settingsLoading, settingsMessage,
- auth, email, password, authMode, apiBaseUrl, appVersion, voiceMetricsText,
+ auth, email, password, authMode, apiBaseUrl, apiBaseUrlSource, defaultApiBaseUrl, appVersion, voiceMetricsText, asrEvaluationText,
onSetLocale, onSetTheme, onSaveProvider, onAdjustSpeed, onSavePracticePreferences,
onLoadPreferences, onSelectVoiceProfile,
onSetEmail, onSetPassword, onSetAuthMode, onSubmitAuth, onSignOut, onSetApiBaseUrl,
- onClearVoiceMetrics, onShareVoiceMetrics, onRunASRDiagnostics,
+ onResetApiBaseUrl, onClearVoiceMetrics, onShareVoiceMetrics, onShareASREvaluation, onRunASRDiagnostics,
}: Props) {
const { C, themeKey } = useTheme()
const speedFill = Math.max(0, Math.min(1, (ttsSpeed - 0.7) / 0.6))
@@ -280,7 +285,14 @@ export function SettingsScreen({
{/* 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 */}
@@ -304,6 +321,9 @@ export function SettingsScreen({
Share
+
+ P4
+
Clear
@@ -312,7 +332,7 @@ export function SettingsScreen({
- {voiceMetricsText || 'No voice metrics yet.'}
+ {voiceMetricsText || asrEvaluationText || 'No voice metrics yet.'}
diff --git a/apps/web/lib/providers/xunfei-voices.ts b/apps/web/lib/providers/xunfei-voices.ts
index 4c25d1a..5d9a172 100644
--- a/apps/web/lib/providers/xunfei-voices.ts
+++ b/apps/web/lib/providers/xunfei-voices.ts
@@ -26,7 +26,7 @@ export const xunfeiVoiceCatalog: XunfeiVoiceInfo[] = [
id: XUNFEI_TRIAL_VOICE_CATHERINE,
name: 'Catherine Professional News',
language: 'en',
- gender: 'male',
+ gender: 'female',
tier: 'featured',
expiresAt: XUNFEI_TRIAL_VOICE_EXPIRES_AT,
},
@@ -34,7 +34,7 @@ export const xunfeiVoiceCatalog: XunfeiVoiceInfo[] = [
id: XUNFEI_TRIAL_VOICE_RYAN,
name: 'Ryan Assistant',
language: 'en',
- gender: 'female',
+ gender: 'male',
tier: 'featured',
expiresAt: XUNFEI_TRIAL_VOICE_EXPIRES_AT,
},
diff --git a/docs/asr-provider-layer.md b/docs/asr-provider-layer.md
index 6affb9a..7f665e8 100644
--- a/docs/asr-provider-layer.md
+++ b/docs/asr-provider-layer.md
@@ -424,31 +424,59 @@ Goal: compare native ASR and remote ASR before changing defaults.
Required metrics:
-- `asr_provider_selected`
+- `stt_start`
+- `stt_first_partial`
+- `stt_submit`
+- `stt_end`
+- `asr_diagnostic_start`
+- `asr_auth_checked`
+- `asr_providers_loaded`
- `asr_session_bootstrap_start`
- `asr_session_bootstrap_end`
-- `asr_speech_start`
+- `asr_stream_start`
+- `asr_pcm_frame`
+- `asr_first_partial`
- `asr_partial`
- `asr_final`
-- `asr_no_speech_timeout`
-- `asr_error`
-- `turn_locked`
-- `ai_reply_start`
-- `tts_playback_start`
+- `asr_stream_done`
+- `asr_stream_provider_error`
+- `asr_diagnostic_error`
+- `coach_reply_ready`
+- `tts_ready`
+- `playback_started`
Evaluation method:
-1. Test native ASR and one remote provider with the same scripted prompts.
-2. Include Chinese, English, and mixed Chinese-English utterances.
-3. Measure time from speech end to `asr_final`.
-4. Measure time from `asr_final` to `ai_reply_start`.
-5. Compare transcript quality manually for names, mixed language, and punctuation.
+1. Clear Settings -> Voice diagnostics logs.
+2. Start a normal mobile session and say the native test prompt.
+3. Wait for the AI reply to complete so native STT, AI, and TTS timing markers are captured.
+4. Open Settings -> Voice diagnostics and tap `ASR`.
+5. Say the same prompt during the 8-second Xunfei diagnostic window.
+6. Tap `P4` to export the compact ASR P4 evaluation report.
+7. Tap `Share` to export raw voice metrics when deeper debugging is needed.
+8. Repeat with Chinese, English, and mixed Chinese-English prompts.
+9. Compare:
+ - native `stt_first_partial.elapsedMs` vs remote `asr_first_partial.elapsedMs`
+ - native `stt_submit.elapsedMs` vs remote `asr_stream_done.streamElapsedMs`
+ - transcript text shown by the native session vs `ASR diagnostic transcript`
+ - provider errors, no-transcript runs, and frame/byte counts
+
+Current mobile P4 implementation:
+
+- File: `apps/mobile/src/App.tsx`
+- Report builder: `createASREvaluationReport(entries)`
+- Native timing source: `stt_start`, `stt_first_partial`, `stt_submit`, `stt_end`
+- Remote timing source: `asr_stream_start`, `asr_first_partial`, `asr_final`, `asr_stream_done`
+- UI entry: Settings -> Voice diagnostics -> `P4`
+- Export title: `MeteorVoice ASR P4 evaluation`
+- Production STT remains unchanged; this report does not switch live sessions to remote ASR.
Done when:
- Remote ASR improves transcript quality enough to justify network cost.
- Speech-end to final latency is acceptable on a real device in China.
- Session does not get stuck after silence, background/foreground, or page transitions.
+- P4 report includes at least one native run and one remote run for the same scripted prompt.
## Testing
diff --git a/docs/tts-integration.md b/docs/tts-integration.md
index 02f9d4d..9fb2d96 100644
--- a/docs/tts-integration.md
+++ b/docs/tts-integration.md
@@ -74,8 +74,8 @@ The adapter sends MP3 output with `aue=lame` and `sfl=1`, matching Xunfei's onli
Current featured voice IDs in the app catalog:
```env
-x4_enus_catherine_profnews # English male
-x4_enus_ryan_assist # English female
+x4_enus_catherine_profnews # English female
+x4_enus_ryan_assist # English male
x4_lingxiaolu_en # Mandarin female
x4_yezi # Mandarin female
```
diff --git a/packages/shared/src/i18n.ts b/packages/shared/src/i18n.ts
index a619fe9..7b06c19 100644
--- a/packages/shared/src/i18n.ts
+++ b/packages/shared/src/i18n.ts
@@ -212,6 +212,9 @@ export const t: Record> = {
'settings.save': 'Save',
'settings.reload': 'Reload',
'settings.api_url': 'API Base URL',
+ 'settings.api_url_reset': 'Reset',
+ 'settings.api_url_source_default': 'Using build default',
+ 'settings.api_url_source_user': 'Using user override',
'settings.language_en': 'English',
'settings.language_zh': '中文',
'settings.sign_out': 'Sign Out',
@@ -460,6 +463,9 @@ export const t: Record> = {
'settings.save': '保存',
'settings.reload': '刷新',
'settings.api_url': 'API 地址',
+ 'settings.api_url_reset': '重置',
+ 'settings.api_url_source_default': '使用构建默认值',
+ 'settings.api_url_source_user': '使用用户覆盖值',
'settings.language_en': 'English',
'settings.language_zh': '中文',
'settings.sign_out': '退出登录',
diff --git a/tests/xunfei-tts.test.ts b/tests/xunfei-tts.test.ts
index bbd22e2..ca97510 100644
--- a/tests/xunfei-tts.test.ts
+++ b/tests/xunfei-tts.test.ts
@@ -62,7 +62,7 @@ describe('Xunfei TTS voice config', () => {
envKey: 'XUNFEI_TTS_VOICE',
id: XUNFEI_TRIAL_VOICE_CATHERINE,
language: 'en',
- gender: 'male',
+ gender: 'female',
tier: 'featured',
status: 'active',
},
From e756319f4aac5e54605eeec5c9ede28b723097b3 Mon Sep 17 00:00:00 2001
From: Connor
Date: Tue, 2 Jun 2026 00:59:14 +0800
Subject: [PATCH 09/37] Integrate Xunfei ASR into mobile sessions (#229)
---
apps/mobile/src/App.tsx | 874 +++++++++++----------
apps/mobile/src/screens/SettingsScreen.tsx | 32 +-
packages/shared/src/i18n.ts | 8 +
3 files changed, 478 insertions(+), 436 deletions(-)
diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx
index 77693c2..1d4fbf0 100644
--- a/apps/mobile/src/App.tsx
+++ b/apps/mobile/src/App.tsx
@@ -78,8 +78,10 @@ import { SettingsScreen } from './screens/SettingsScreen'
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
@@ -226,6 +228,120 @@ function withTimeout(promise: Promise, timeoutMs: number, message: string)
})
}
+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 (
@@ -276,6 +392,8 @@ function AppInner() {
const [settingsMessage, setSettingsMessage] = useState(null)
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)
@@ -289,6 +407,13 @@ function AppInner() {
const [apiSessionId] = useState(null)
const [selectedScenarioKey, setSelectedScenarioKey] = useState('small-talk')
const [selectedAccentKey, setSelectedAccentKey] = useState('american')
+ useEffect(() => {
+ SecureStore.getItemAsync(sessionSttProviderStorageKey).then(value => {
+ if (value === 'xunfei' || value === 'native') {
+ setSessionSttProviderState(value)
+ }
+ })
+ }, [])
const ttsSpeedRouting = getTTSSpeedRouting(ttsProvider, ttsSpeed)
const audio = useNativeSessionAudio(audioUrl, ttsSpeedRouting.playbackRate)
const auth = useMobileAuth()
@@ -298,6 +423,16 @@ function AppInner() {
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
+ settled: boolean
+ } | null>(null)
const endpointRequestRef = useRef(0)
const turnRequestRef = useRef(0)
const sessionActiveRef = useRef(false)
@@ -309,7 +444,6 @@ function AppInner() {
const pendingNativeTranscriptRef = useRef('')
const isCorrectionPlayingRef = useRef(false)
const resumeListeningTimerRef = useRef | null>(null)
- const asrDiagnosticActiveRef = useRef(false)
const historyAutoLoadRef = useRef(false)
const settingsAutoLoadRef = useRef(false)
@@ -399,6 +533,12 @@ function AppInner() {
void SecureStore.deleteItemAsync(apiBaseUrlStorageKey)
}, [])
+ const setSessionSttProvider = useCallback((provider: SessionSttProvider) => {
+ setSessionSttProviderState(provider)
+ void SecureStore.setItemAsync(sessionSttProviderStorageKey, provider)
+ logVoiceMetric('stt_provider_selected', { provider })
+ }, [logVoiceMetric, setSessionSttProviderState])
+
function startSession() {
if (auth.state !== 'signed-in') {
setActiveTab('settings')
@@ -750,318 +890,131 @@ function AppInner() {
scheduleResumeListening(250, false)
}, [busy, logVoiceMetric, scheduleResumeListening])
- const speech = useNativeSpeech({
- onFinalTranscript: handleNativeFinalTranscript,
- onListeningEndedWithoutTranscript: handleListeningEndedWithoutTranscript,
- onMetric: logVoiceMetric,
- })
-
- useEffect(() => {
- speechStartListeningRef.current = speech.startListening
- speechCancelListeningRef.current = speech.cancelListening
- }, [speech.cancelListening, speech.startListening])
-
- useEffect(() => {
- sessionActiveRef.current = isSessionActive
- }, [isSessionActive])
-
- const selectTab = useCallback((tab: Tab) => {
- setActiveTab(tab)
- if (tab !== 'session') {
- canListenOnRouteRef.current = false
- playbackEndedAtMsRef.current = null
- clearResumeListeningTimer()
- endpointRequestRef.current += 1
- pendingNativeTranscriptRef.current = ''
- void speechCancelListeningRef.current()
- if (sessionActiveRef.current) setStatus('session.paused')
- return
+ 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)
+ current.frameSubscription?.remove()
+ current.stateSubscription?.remove()
+ if (current.socket && current.socket.readyState === WebSocket.OPEN) {
+ current.socket.close()
}
-
- canListenOnRouteRef.current = true
- if (sessionActiveRef.current && !busy && !playbackActiveRef.current && !audioPlayingRef.current) {
- listeningStartMsRef.current = Date.now()
- setStatus('session.status.listening')
- void speechStartListeningRef.current('en-US')
- }
- }, [busy, clearResumeListeningTimer])
-
- async function endSession() {
- if (!canEndSession({ activeSession: isSessionActive, workflowState: snapshot.state })) return
-
- // 立即结束 session,不等 API
- turnRequestRef.current += 1
- sessionActiveRef.current = false
- canListenOnRouteRef.current = false
- playbackActiveRef.current = false
- playbackEndedAtMsRef.current = null
- clearResumeListeningTimer()
- endpointRequestRef.current += 1
- pendingNativeTranscriptRef.current = ''
- void speechCancelListeningRef.current()
- const endedSnapshot = endActiveSession(snapshot).snapshot
- setSnapshot(endedSnapshot)
- setIsSessionActive(false)
- setStatus('session.ended')
- setBusy(false)
-
- const userTurns = messages.filter(m => m.role === 'user').length
- try {
- const result = await api.generateSummary({
- sessionId: snapshot.sessionId,
- scenario: scenario.name,
- messages,
- turnNumber: userTurns,
+ await stopPcmCapture(`session_${reason}`).catch(() => undefined)
+ xunfeiSessionSttRef.current = null
+ logVoiceMetric('stt_end', { provider: 'xunfei', cancelled: true, reason })
+ }, [logVoiceMetric])
+
+ const startXunfeiSessionListening = useCallback(async () => {
+ 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',
})
- setSummary(result.summary)
- await api.syncSession({
- session_id: snapshot.sessionId,
- scenario: scenario.name,
- accent: accent.name,
- turns: userTurns,
- messages,
- corrections: correctionHistory,
- }).catch(() => undefined)
- } catch {
- // summary 失败不影响 session 已结束的状态
+ return nativeSpeechStartListeningRef.current('en-US')
}
- }
-
- 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')
- }
-
- const loadHistory = useCallback(async () => {
- if (historyLoading) return
- setHistoryLoading(true)
- setHistoryError(null)
- try {
- const result = await api.listHistory()
- setHistorySessions(result.sessions)
- setSelectedHistory(result.sessions[0] ?? null)
- setSelectedHistoryTurns([])
- } catch (error) {
- const requestError = formatApiRequestError(error, {
- context: 'mobile_history_list',
- presentation: 'inline',
- })
- setHistoryError(requestError.displayMessage)
- } finally {
- setHistoryLoading(false)
+ 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 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,
+ settled,
+ }
}
- }, [api, historyLoading])
-
- useEffect(() => {
- if (activeTab !== 'history' || historyAutoLoadRef.current) return
- historyAutoLoadRef.current = true
- void loadHistory()
- }, [activeTab, loadHistory])
- async function runASRDiagnostics() {
- if (asrDiagnosticActiveRef.current) {
- setSettingsMessage('ASR diagnostic is already running.')
- logVoiceMetric('asr_diagnostic_skipped', { reason: 'already_running' })
- return
+ const settle = (reason: string, submitted: boolean) => {
+ if (settled) return
+ settled = true
+ updateCurrent()
+ if (finalizeTimer) clearTimeout(finalizeTimer)
+ if (hardTimer) clearTimeout(hardTimer)
+ 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,
+ })
}
- if (auth.state !== 'signed-in') {
- setActiveTab('settings')
- setSettingsMessage('Sign in before running ASR diagnostics.')
- logVoiceMetric('asr_diagnostic_skipped', { reason: 'signed_out' })
- return
+ 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)))
}
- asrDiagnosticActiveRef.current = true
- const startedAt = Date.now()
- logVoiceMetric('asr_diagnostic_start')
- setSettingsMessage('Checking ASR providers...')
-
try {
const authReady = await auth.refreshSession()
- const authHeaders = getAuthHeaders() as Record
- const hasAuthorization = Boolean(authHeaders.Authorization)
- logVoiceMetric('asr_auth_checked', {
- refreshed: authReady,
- hasAuthorization,
- })
- if (!authReady || !hasAuthorization) {
- setSettingsMessage('Sign in again before running ASR diagnostics.')
- logVoiceMetric('asr_auth_missing', { elapsedMs: Date.now() - startedAt })
- return
- }
+ if (!authReady) return nativeSpeechStartListeningRef.current('en-US')
- const providers = await api.listASRProviders()
- const selected = providers.providers.find(provider => provider.key === 'xunfei' && provider.enabled)
- ?? providers.providers.find(provider => provider.key === providers.default_provider)
- logVoiceMetric('asr_providers_loaded', {
- defaultProvider: providers.default_provider,
- enabled: providers.providers.filter(provider => provider.enabled).map(provider => provider.key).join(','),
- })
-
- if (!selected || selected.key === 'native') {
- setSettingsMessage('ASR remote provider is not configured. Native STT remains active.')
- logVoiceMetric('asr_diagnostic_done', {
- provider: selected?.key ?? 'none',
- remoteReady: false,
- elapsedMs: Date.now() - startedAt,
- })
- return
- }
-
- logVoiceMetric('asr_session_bootstrap_start', {
- provider: selected.key,
- elapsedMs: Date.now() - startedAt,
- })
const session = await api.createASRSession({
- provider: selected.key,
+ provider: 'xunfei',
mode: 'streaming',
languageMode: 'mixed_zh_en',
scenarioKey: scenario.key,
sessionId: snapshot.sessionId,
endpointSilenceMs: 900,
- clientTraceId: `mobile-${Date.now()}`,
- })
- logVoiceMetric('asr_session_bootstrap_end', {
- provider: session.provider,
- status: session.status,
- transport: session.transport,
- elapsedMs: Date.now() - startedAt,
+ clientTraceId: `mobile-session-${Date.now()}`,
})
- if (session.provider === 'xunfei' && session.status === 'created' && session.transport === 'websocket') {
- await runXunfeiASRStreamingDiagnostic(session, startedAt)
- return
- }
- setSettingsMessage(`ASR ${session.provider} bootstrap ready (${session.transport}). Native STT still active.`)
- } catch (error) {
- const requestError = formatApiRequestError(error, {
- context: 'asr_diagnostic',
- presentation: 'inline',
- })
- if (requestError.kind === 'unauthorized') {
- logVoiceMetric('asr_session_unauthorized', {
- ...requestError.logData,
- elapsedMs: Date.now() - startedAt,
- })
- setSettingsMessage(requestError.displayMessage)
- return
+ 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('asr_diagnostic_error', {
- ...requestError.logData,
- elapsedMs: Date.now() - startedAt,
- })
- setSettingsMessage(requestError.displayMessage)
- } finally {
- asrDiagnosticActiveRef.current = false
- }
- }
-
- async function runXunfeiASRStreamingDiagnostic(session: CreateASRSessionResponse, startedAt: number) {
- if (!session.endpointUrl) {
- setSettingsMessage('ASR bootstrap returned no WebSocket URL.')
- logVoiceMetric('asr_stream_skipped', { reason: 'missing_endpoint_url' })
- return
- }
- if (!isPcmCaptureAvailable()) {
- setSettingsMessage('ASR bootstrap ready, but native PCM capture is unavailable in this build.')
- logVoiceMetric('asr_stream_skipped', { reason: 'pcm_module_unavailable' })
- return
- }
-
- clearResumeListeningTimer()
- await speechCancelListeningRef.current()
- setSettingsMessage('ASR streaming diagnostic is listening for 8 seconds. Speak now.')
- logVoiceMetric('asr_stream_start', {
- provider: session.provider,
- sampleRate: session.providerConfig?.sampleRate ?? 16000,
- frameSizeBytes: session.providerConfig?.frameSizeBytes ?? 1280,
- })
- try {
- const result = await runXunfeiDiagnosticWebSocket(session)
- logVoiceMetric('asr_stream_done', {
- elapsedMs: Date.now() - startedAt,
- streamElapsedMs: result.streamElapsedMs,
- firstPartialElapsedMs: result.firstPartialElapsedMs,
- frameCount: result.frameCount,
- totalBytes: result.totalBytes,
- transcriptChars: result.transcript.length,
- })
- setSettingsMessage(result.transcript.trim()
- ? `ASR diagnostic transcript: ${result.transcript.trim()}`
- : `ASR diagnostic finished. Frames: ${result.frameCount}, bytes: ${result.totalBytes}.`)
- } finally {
- // runASRDiagnostics owns the active flag so bootstrap failures and stream runs share one lock.
- }
- }
+ logVoiceMetric('stt_start', { provider: 'xunfei' })
+ listeningStartMsRef.current = streamStartedAt
+ setStatus('session.status.listening')
+ await nativeSpeechCancelListeningRef.current()
- function runXunfeiDiagnosticWebSocket(session: CreateASRSessionResponse) {
- return new Promise<{ frameCount: number; totalBytes: number; transcript: string; streamElapsedMs: number; firstPartialElapsedMs: number | null }>((resolve, reject) => {
- let socket: WebSocket | null = null
- let frameSubscription: { remove: () => void } | null = null
- let stateSubscription: { remove: () => void } | null = null
- let stopTimer: ReturnType | null = null
- let hardTimer: ReturnType | null = null
- let settled = false
- let firstFrame = true
- let finalFrameSent = false
- let audioSequence = 0
- let frameCount = 0
- let totalBytes = 0
- let transcript = ''
- const transcriptSegments: string[] = []
- const streamStartedAt = Date.now()
- let firstPartialElapsedMs: number | null = null
- let finalReceived = false
- let providerMessageCount = 0
- let lastProviderCode: number | null = null
- let lastProviderStatus: number | null = null
- let lastProviderMessage: string | null = null
-
- const settle = (callback: () => void) => {
- if (settled) return
- settled = true
- if (stopTimer) clearTimeout(stopTimer)
- if (hardTimer) clearTimeout(hardTimer)
- frameSubscription?.remove()
- void stopPcmCapture('diagnostic_settled')
- .catch(() => undefined)
- .finally(() => stateSubscription?.remove())
- if (socket && socket.readyState === WebSocket.OPEN) {
- socket.close()
- }
- callback()
- }
-
- const sendAudioFrame = (status: 0 | 1 | 2, audioBase64: string) => {
- if (!socket || socket.readyState !== WebSocket.OPEN || finalFrameSent) return
- if (status === 2) finalFrameSent = true
- audioSequence += 1
- socket.send(JSON.stringify(createXunfeiASRFrame(session, status, audioBase64, audioSequence)))
- }
+ socket = new WebSocket(session.endpointUrl)
+ updateCurrent()
const finishAudio = () => {
if (finalFrameSent) return
- sendAudioFrame(2, '')
- void stopPcmCapture('diagnostic_timeout').catch(() => undefined)
+ sendAudioFrame(2, '', session)
+ void stopPcmCapture('session_endpoint').catch(() => undefined)
}
- try {
- socket = new WebSocket(session.endpointUrl ?? '')
- } catch (error) {
- settle(() => reject(error))
- return
+ const scheduleFinalize = () => {
+ if (finalizeTimer) clearTimeout(finalizeTimer)
+ finalizeTimer = setTimeout(finishAudio, session.providerConfig?.eosMs ?? 900)
+ updateCurrent()
}
stateSubscription = addPcmStateListener(event => {
- logVoiceMetric('asr_pcm_state', {
+ logVoiceMetric('stt_pcm_state', {
+ provider: 'xunfei',
state: event.state,
frameCount: event.frameCount,
totalBytes: event.totalBytes,
@@ -1073,10 +1026,11 @@ function AppInner() {
if (!socket || socket.readyState !== WebSocket.OPEN || finalFrameSent) return
frameCount += 1
totalBytes += event.byteCount
- sendAudioFrame(firstFrame ? 0 : 1, event.audioBase64)
+ sendAudioFrame(firstFrame ? 0 : 1, event.audioBase64, session)
firstFrame = false
- if (frameCount === 1 || frameCount % 25 === 0) {
- logVoiceMetric('asr_pcm_frame', {
+ if (frameCount === 1 || frameCount % 50 === 0) {
+ logVoiceMetric('stt_pcm_frame', {
+ provider: 'xunfei',
frameCount,
totalBytes,
elapsedMs: event.elapsedMs,
@@ -1089,16 +1043,18 @@ function AppInner() {
sampleRate: session.providerConfig?.sampleRate ?? 16000,
frameDurationMs: session.providerConfig?.frameIntervalMs ?? 40,
}).catch(error => {
- settle(() => reject(error))
+ logVoiceMetric('stt_provider_error', {
+ provider: 'xunfei',
+ message: error instanceof Error ? error.message : 'PCM capture failed',
+ })
+ settle('pcm_error', false)
+ handleListeningEndedWithoutTranscript()
})
- stopTimer = setTimeout(finishAudio, 8000)
- hardTimer = setTimeout(() => {
- settle(() => resolve({ frameCount, totalBytes, transcript, streamElapsedMs: Date.now() - streamStartedAt, firstPartialElapsedMs }))
- }, 12000)
+ hardTimer = setTimeout(finishAudio, 15_000)
+ updateCurrent()
}
socket.onmessage = event => {
- providerMessageCount += 1
const payload = parseJsonObject(event.data)
const header = getObject(payload?.header)
const code = typeof header?.code === 'number'
@@ -1106,64 +1062,76 @@ function AppInner() {
: typeof payload?.code === 'number'
? payload.code
: 0
- lastProviderCode = code
- lastProviderMessage = typeof header?.message === 'string'
- ? header.message
- : typeof payload?.message === 'string'
- ? payload.message
- : null
if (code !== 0) {
- const message = lastProviderMessage ?? `Xunfei ASR error ${code}`
- logVoiceMetric('asr_stream_provider_error', { code, message })
- settle(() => reject(new Error(message)))
+ 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] = ''
- }
+ 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('')
- if (firstPartialElapsedMs == null) {
- firstPartialElapsedMs = Date.now() - streamStartedAt
- logVoiceMetric('asr_first_partial', {
- elapsedMs: firstPartialElapsedMs,
+ transcript = transcriptSegments.filter(Boolean).join('').trim()
+ if (!firstPartialAt) {
+ firstPartialAt = Date.now()
+ logVoiceMetric('stt_first_partial', {
+ provider: 'xunfei',
+ elapsedMs: firstPartialAt - streamStartedAt,
chars: transcript.length,
})
}
- logVoiceMetric('asr_partial', { 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
- lastProviderStatus = status ?? null
if (status === 2) {
finalReceived = true
- logVoiceMetric('asr_final', {
- elapsedMs: Date.now() - streamStartedAt,
- chars: transcript.length,
- })
- settle(() => resolve({ frameCount, totalBytes, transcript, streamElapsedMs: Date.now() - streamStartedAt, firstPartialElapsedMs }))
+ 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 = () => {
- settle(() => reject(new Error('Xunfei ASR WebSocket error')))
+ logVoiceMetric('stt_provider_error', { provider: 'xunfei', message: 'WebSocket error' })
+ settle('socket_error', false)
+ handleListeningEndedWithoutTranscript()
}
socket.onclose = event => {
- const closeData = {
+ 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),
@@ -1171,133 +1139,154 @@ function AppInner() {
finalFrameSent,
frameCount,
totalBytes,
- providerMessageCount,
- lastProviderCode,
- lastProviderStatus,
- lastProviderMessage,
- }
- logVoiceMetric('asr_stream_socket_close', closeData)
- if (!finalReceived && frameCount < 10) {
- logVoiceMetric('asr_stream_closed_early', closeData)
+ })
+ if (!finalReceived && !settled) {
+ settle('socket_closed', false)
+ handleListeningEndedWithoutTranscript()
}
- settle(() => resolve({ frameCount, totalBytes, transcript, streamElapsedMs: Date.now() - streamStartedAt, firstPartialElapsedMs }))
}
- })
- }
- 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 },
- }
+ 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, snapshot.sessionId,
+ ])
- 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 },
+ const speech = useNativeSpeech({
+ onFinalTranscript: handleNativeFinalTranscript,
+ onListeningEndedWithoutTranscript: handleListeningEndedWithoutTranscript,
+ onMetric: logVoiceMetric,
+ })
+
+ useEffect(() => {
+ 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
+ }, [
+ cancelXunfeiSessionListening, sessionSttProvider, speech.cancelListening, speech.startListening,
+ startXunfeiSessionListening,
+ ])
+
+ useEffect(() => {
+ sessionActiveRef.current = isSessionActive
+ }, [isSessionActive])
+
+ const selectTab = useCallback((tab: Tab) => {
+ setActiveTab(tab)
+ if (tab !== 'session') {
+ canListenOnRouteRef.current = false
+ playbackEndedAtMsRef.current = null
+ clearResumeListeningTimer()
+ endpointRequestRef.current += 1
+ pendingNativeTranscriptRef.current = ''
+ void speechCancelListeningRef.current()
+ if (sessionActiveRef.current) setStatus('session.paused')
+ return
}
- }
- 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
+ canListenOnRouteRef.current = true
+ if (sessionActiveRef.current && !busy && !playbackActiveRef.current && !audioPlayingRef.current) {
+ listeningStartMsRef.current = Date.now()
+ setStatus('session.status.listening')
+ void speechStartListeningRef.current('en-US')
}
- }
+ }, [busy, clearResumeListeningTimer])
- function getObject(value: unknown): Record | null {
- return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null
- }
+ async function endSession() {
+ if (!canEndSession({ activeSession: isSessionActive, workflowState: snapshot.state })) return
- 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,
- }
- }
- }
+ // 立即结束 session,不等 API
+ turnRequestRef.current += 1
+ sessionActiveRef.current = false
+ canListenOnRouteRef.current = false
+ playbackActiveRef.current = false
+ playbackEndedAtMsRef.current = null
+ clearResumeListeningTimer()
+ endpointRequestRef.current += 1
+ pendingNativeTranscriptRef.current = ''
+ void speechCancelListeningRef.current()
+ const endedSnapshot = endActiveSession(snapshot).snapshot
+ setSnapshot(endedSnapshot)
+ setIsSessionActive(false)
+ setStatus('session.ended')
+ setBusy(false)
- 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
+ const userTurns = messages.filter(m => m.role === 'user').length
+ try {
+ const result = await api.generateSummary({
+ sessionId: snapshot.sessionId,
+ scenario: scenario.name,
+ messages,
+ turnNumber: userTurns,
+ })
+ setSummary(result.summary)
+ await api.syncSession({
+ session_id: snapshot.sessionId,
+ scenario: scenario.name,
+ accent: accent.name,
+ turns: userTurns,
+ messages,
+ corrections: correctionHistory,
+ }).catch(() => undefined)
+ } catch {
+ // summary 失败不影响 session 已结束的状态
+ }
}
- 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 selectScenario(key: string) {
+ setSelectedScenarioKey(key)
+ setMessages([])
+ setCorrectionHistory([])
+ setAudioUrl(null)
+ setPlaybackQueue(createPlaybackQueueSnapshot())
+ setSummary(null)
+ setSnapshot(createInitialSnapshot('mobile-session'))
+ setIsSessionActive(false)
+ setStatus('session.status.scenario_selected')
}
- function decodeBase64Utf8(value: string) {
+ const loadHistory = useCallback(async () => {
+ if (historyLoading) return
+ setHistoryLoading(true)
+ setHistoryError(null)
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 ''
+ const result = await api.listHistory()
+ setHistorySessions(result.sessions)
+ setSelectedHistory(result.sessions[0] ?? null)
+ setSelectedHistoryTurns([])
+ } catch (error) {
+ const requestError = formatApiRequestError(error, {
+ context: 'mobile_history_list',
+ presentation: 'inline',
+ })
+ setHistoryError(requestError.displayMessage)
+ } finally {
+ setHistoryLoading(false)
}
- }
+ }, [api, historyLoading])
+
+ useEffect(() => {
+ if (activeTab !== 'history' || historyAutoLoadRef.current) return
+ historyAutoLoadRef.current = true
+ void loadHistory()
+ }, [activeTab, loadHistory])
async function deleteSession(id: string) {
setHistorySessions(prev => prev.map(s => s.id === id ? { ...s, status: 'deleted' } : s))
@@ -1523,6 +1512,31 @@ function AppInner() {
.catch(() => undefined)
}, [api, applyServerPreferences])
+ useEffect(() => {
+ let cancelled = false
+ void api.listASRProviders()
+ .then(result => {
+ if (cancelled) return
+ const providers: SessionSttProvider[] = ['native']
+ if (result.providers.some(provider => provider.key === 'xunfei' && provider.enabled)) {
+ providers.push('xunfei')
+ }
+ setAvailableSessionSttProviders(providers)
+ if (!providers.includes(sessionSttProvider)) {
+ 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 () => { cancelled = true }
+ }, [api, logVoiceMetric, sessionSttProvider])
+
// 登录后自动拉取偏好
useEffect(() => {
if (auth.state !== 'signed-in') return
@@ -1632,6 +1646,8 @@ function AppInner() {
locale={locale}
ttsProvider={ttsProvider}
availableProviders={availableProviders}
+ sessionSttProvider={sessionSttProvider}
+ availableSessionSttProviders={availableSessionSttProviders}
ttsSpeed={ttsSpeed}
ttsVoiceId={ttsVoiceId}
voiceProfiles={voiceProfiles}
@@ -1652,6 +1668,7 @@ function AppInner() {
onSetLocale={l => setLocale(l as Locale)}
onSetTheme={setTheme}
onSaveProvider={p => void saveProvider(p)}
+ onSetSessionSttProvider={setSessionSttProvider}
onAdjustSpeed={adjustSpeed}
onSavePracticePreferences={() => void savePracticePreferences()}
onLoadPreferences={() => void loadPreferences()}
@@ -1676,7 +1693,6 @@ function AppInner() {
message: asrEvaluationText,
})
}}
- onRunASRDiagnostics={() => void runASRDiagnostics()}
/>
)
}
diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx
index ead266a..cf4e05c 100644
--- a/apps/mobile/src/screens/SettingsScreen.tsx
+++ b/apps/mobile/src/screens/SettingsScreen.tsx
@@ -11,6 +11,8 @@ interface Props {
locale: Locale
ttsProvider: string
availableProviders: string[]
+ sessionSttProvider: 'native' | 'xunfei'
+ availableSessionSttProviders: Array<'native' | 'xunfei'>
ttsSpeed: number
ttsVoiceId: string | null
voiceProfiles: VoiceProfile[]
@@ -31,6 +33,7 @@ interface Props {
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
@@ -45,18 +48,17 @@ interface Props {
onClearVoiceMetrics: () => void
onShareVoiceMetrics: () => void
onShareASREvaluation: () => void
- onRunASRDiagnostics: () => 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, apiBaseUrlSource, defaultApiBaseUrl, appVersion, voiceMetricsText, asrEvaluationText,
- onSetLocale, onSetTheme, onSaveProvider, onAdjustSpeed, onSavePracticePreferences,
+ onSetLocale, onSetTheme, onSaveProvider, onSetSessionSttProvider, onAdjustSpeed, onSavePracticePreferences,
onLoadPreferences, onSelectVoiceProfile,
onSetEmail, onSetPassword, onSetAuthMode, onSubmitAuth, onSignOut, onSetApiBaseUrl,
- onResetApiBaseUrl, onClearVoiceMetrics, onShareVoiceMetrics, onShareASREvaluation, onRunASRDiagnostics,
+ onResetApiBaseUrl, onClearVoiceMetrics, onShareVoiceMetrics, onShareASREvaluation,
}: Props) {
const { C, themeKey } = useTheme()
const speedFill = Math.max(0, Math.min(1, (ttsSpeed - 0.7) / 0.6))
@@ -215,6 +217,25 @@ export function SettingsScreen({
{settingsMessage && {settingsMessage}}
+ {/* Session STT Provider */}
+
+ {tr('settings.session_stt_provider')}
+
+ {availableSessionSttProviders.map(provider => (
+ onSetSessionSttProvider(provider)}
+ style={[styles.chip, sessionSttProvider === provider && styles.chipActive]}
+ >
+
+ {tr(`settings.session_stt_provider_${provider}`)}
+
+
+ ))}
+
+ {tr('settings.session_stt_provider_hint')}
+
+
{/* Coach Voice */}
{tr('settings.voice_profile_current')}
@@ -315,9 +336,6 @@ export function SettingsScreen({
Voice diagnostics
-
- ASR
-
Share
diff --git a/packages/shared/src/i18n.ts b/packages/shared/src/i18n.ts
index 7b06c19..6171072 100644
--- a/packages/shared/src/i18n.ts
+++ b/packages/shared/src/i18n.ts
@@ -176,6 +176,10 @@ export const t: Record> = {
'settings.tts_provider_tencent': 'Tencent Cloud',
'settings.tts_provider_azure': 'Azure Neural TTS',
'settings.tts_not_configured': 'Not configured',
+ 'settings.session_stt_provider': 'Session Speech Recognition',
+ 'settings.session_stt_provider_native': 'Native',
+ 'settings.session_stt_provider_xunfei': 'Xunfei',
+ 'settings.session_stt_provider_hint': 'Native remains the default. Xunfei streams microphone PCM to the remote ASR provider during practice sessions.',
'settings.xunfei_voice_config': 'Xunfei voice configuration',
'settings.xunfei_voice_current': 'Current coach voice',
'settings.xunfei_voice_available': 'Available',
@@ -427,6 +431,10 @@ export const t: Record> = {
'settings.tts_provider_tencent': '腾讯云',
'settings.tts_provider_azure': 'Azure Neural TTS',
'settings.tts_not_configured': '未配置',
+ 'settings.session_stt_provider': '会话语音识别',
+ 'settings.session_stt_provider_native': '系统识别',
+ 'settings.session_stt_provider_xunfei': '讯飞',
+ 'settings.session_stt_provider_hint': '默认仍使用系统识别。选择讯飞后,会话中会把本地麦克风 PCM 流式发送到远端 ASR。',
'settings.xunfei_voice_config': '讯飞发音人配置',
'settings.xunfei_voice_current': '当前教练发音人',
'settings.xunfei_voice_available': '可用',
From 18235c06c1a1a2c1ee0106c2d0d89982b83f09dc Mon Sep 17 00:00:00 2001
From: Connor
Date: Wed, 3 Jun 2026 00:56:28 +0800
Subject: [PATCH 10/37] Normalize deployment env ownership and Tencent workflow
(#231)
---
.env.local.example | 32 -----------
.github/workflows/deploy-tencent.yml | 51 ++++++++++++++++++
AGENTS.md | 8 +--
apps/mobile/.env.example | 2 +
apps/mobile/assets/icon.png | Bin 15468 -> 13267 bytes
.../App-Icon-1024x1024@1x.png | Bin 21641 -> 11749 bytes
apps/mobile/ios/MeteorVoice/Info.plist | 2 +-
docs/plan.md | 2 +-
docs/spec.md | 2 +-
docs/supabase-setup.md | 2 +-
docs/tts-integration.md | 4 +-
packages/shared/src/i18n.ts | 4 +-
12 files changed, 66 insertions(+), 43 deletions(-)
delete mode 100644 .env.local.example
create mode 100644 .github/workflows/deploy-tencent.yml
create mode 100644 apps/mobile/.env.example
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/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml
new file mode 100644
index 0000000..001adff
--- /dev/null
+++ b/.github/workflows/deploy-tencent.yml
@@ -0,0 +1,51 @@
+name: Deploy Tencent
+
+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=3102" >> "$GITHUB_OUTPUT"
+ else
+ echo "app_dir=/srv/meteorvoice" >> "$GITHUB_OUTPUT"
+ echo "pm2_name=meteorvoice" >> "$GITHUB_OUTPUT"
+ echo "port=3100" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Deploy
+ shell: bash
+ run: |
+ cd '${{ steps.target.outputs.app_dir }}'
+ git fetch origin '${GITHUB_REF_NAME}'
+ git checkout '${GITHUB_REF_NAME}'
+ git reset --hard 'origin/${GITHUB_REF_NAME}'
+ npm ci
+ set -a
+ . /etc/meteorvoice/meteorvoice.env
+ set +a
+ npm run build
+ 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
+ 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/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/assets/icon.png b/apps/mobile/assets/icon.png
index cd17172b104b83df2e0703e9c11aa6a7b73b1d68..b918023d2e1ece1085ad2e18415d34cec20e9aa9 100644
GIT binary patch
literal 13267
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
CMXI|1
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 b43044e173958aca34a9d2575b6e639fd2593579..3c1e163ea2ed44d8b814d6191e966cc54310f693 100644
GIT binary patch
literal 11749
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
JZ$0hw{{U6+;@toM
literal 21641
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