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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 GUIViewControllerBasedStatusBarAppearance - \ No newline at end of file + diff --git a/docs/plan.md b/docs/plan.md index 4997cc9..1001ade 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -85,7 +85,7 @@ Recommended default stack: - AI workflow: LangGraph as an in-app library for the conversation workflow layer; do not deploy a separate LangGraph server in the MVP - AI utilities: LangChain only for prompt templates, provider adapters, structured output helpers, or future tool integration - Speech: browser microphone capture, STT/TTS abstraction, and mock providers for local demo -- Env: `.env.local` for optional AI model, STT, and TTS provider keys +- Env: `apps/web/.env.local` for optional AI model, STT, and TTS provider keys; deployed servers use `/etc/meteorvoice/meteorvoice.env` Current implementation status: diff --git a/docs/spec.md b/docs/spec.md index 2fb6aaa..4c49d1a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -139,7 +139,7 @@ Recommended approach: - Prefer Vercel AI SDK as the primary AI request layer when building with TypeScript - Use LangGraph for multi-step learning flows from the start, but keep it isolated in a `conversationWorkflow` layer - Do not introduce LangChain broadly unless it removes real complexity -- Keep all external AI and speech provider keys in `.env.local` +- Keep all external AI and speech provider keys in Web/API runtime environment files such as `apps/web/.env.local` for local development and `/etc/meteorvoice/meteorvoice.env` for server deployments. - Keep paid provider usage optional in the MVP through mock providers and adapter interfaces - Make device switching and mobile access first-class requirements, not future enhancements - If a different stack is already preferred for implementation, keep these same product and cost constraints while swapping the concrete tools. diff --git a/docs/supabase-setup.md b/docs/supabase-setup.md index d45fb76..8fed3c8 100644 --- a/docs/supabase-setup.md +++ b/docs/supabase-setup.md @@ -44,7 +44,7 @@ This gives you a single login surface with two formal account types: 4. Run `003_tts_preferences.sql`. 5. Run `004_productized_preferences.sql`. 6. Run `005_tts_voice_preferences.sql` through `008_voice_profile_preferences.sql`. -7. Copy the project URL and anon key into `.env.local`. +7. Copy the project URL and anon key into `apps/web/.env.local` for Web/API local development and `apps/mobile/.env` using Expo's `EXPO_PUBLIC_*` names for mobile builds. 8. Set Authentication redirect URLs for local development. ## What the RLS Policies Do diff --git a/docs/tts-integration.md b/docs/tts-integration.md index 9fb2d96..0891d9e 100644 --- a/docs/tts-integration.md +++ b/docs/tts-integration.md @@ -25,7 +25,7 @@ Do not store provider API keys in the database for the MVP. Keep keys in server- ## Environment Variables -Local development uses `.env.local`. +Local Web/API development uses `apps/web/.env.local`. The deployed Tencent server uses `/etc/meteorvoice/meteorvoice.env`. Vercel or another deployment platform should use provider-managed environment variables instead. Do not commit real keys. @@ -55,7 +55,7 @@ XUNFEI_API_KEY= XUNFEI_API_SECRET= ``` -5. Configure `.env.local` or deployment env vars: +5. Configure `apps/web/.env.local`, `/etc/meteorvoice/meteorvoice.env`, or deployment env vars: ```env TTS_PROVIDER=xunfei diff --git a/packages/shared/src/i18n.ts b/packages/shared/src/i18n.ts index 6171072..6386ef0 100644 --- a/packages/shared/src/i18n.ts +++ b/packages/shared/src/i18n.ts @@ -161,7 +161,7 @@ export const t: Record> = { 'settings.language_desc': 'Interface display language.', 'settings.ai_provider': 'AI Provider', 'settings.ai_mode': 'Mode', - 'settings.ai_hint': 'Add DEEPSEEK_API_KEY to .env.local to switch to the real AI provider.', + 'settings.ai_hint': 'Add DEEPSEEK_API_KEY to apps/web/.env.local or the server runtime env to switch to the real AI provider.', 'settings.tts_provider': 'Voice Provider', 'settings.tts_provider_desc': 'Choose the text-to-speech provider used during conversation.', 'settings.tts_provider_hint': 'Provider keys stay on the server. If a provider is not configured, voice playback will fail for that provider.', @@ -416,7 +416,7 @@ export const t: Record> = { 'settings.language_desc': '界面显示语言。', 'settings.ai_provider': 'AI 服务商', 'settings.ai_mode': '模式', - 'settings.ai_hint': '在 .env.local 中添加 DEEPSEEK_API_KEY 即可切换真实 AI。', + 'settings.ai_hint': '在 apps/web/.env.local 或服务器运行环境中添加 DEEPSEEK_API_KEY 即可切换真实 AI。', 'settings.tts_provider': '语音服务商', 'settings.tts_provider_desc': '选择对话中使用的文本转语音服务商。', 'settings.tts_provider_hint': '服务商密钥只保存在服务端。如果未配置对应服务商,选择后将无法播放真实语音。', From 74c3cb4378619e80504caf69feb961f0f6389764 Mon Sep 17 00:00:00 2001 From: Connor Date: Wed, 3 Jun 2026 01:10:34 +0800 Subject: [PATCH 11/37] Add retry handling to Tencent deploy workflow (#233) --- .github/workflows/deploy-tencent.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index 001adff..a9cbbcc 100644 --- a/.github/workflows/deploy-tencent.yml +++ b/.github/workflows/deploy-tencent.yml @@ -34,15 +34,32 @@ jobs: - name: Deploy shell: bash run: | + retry() { + local attempts="$1" + local delay="$2" + shift 2 + + local attempt=1 + until "$@"; do + if [ "$attempt" -ge "$attempts" ]; then + return 1 + fi + + echo "Command failed. Retrying in ${delay}s (${attempt}/${attempts})..." + sleep "$delay" + attempt=$((attempt + 1)) + done + } + cd '${{ steps.target.outputs.app_dir }}' - git fetch origin '${GITHUB_REF_NAME}' + retry 5 15 timeout 180s git fetch origin '${GITHUB_REF_NAME}' git checkout '${GITHUB_REF_NAME}' git reset --hard 'origin/${GITHUB_REF_NAME}' - npm ci + retry 3 15 npm ci set -a . /etc/meteorvoice/meteorvoice.env set +a - npm run build + retry 2 15 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 \ From d583d92ebd5b7fb0b464b0456cb7adcd10f4ec16 Mon Sep 17 00:00:00 2001 From: Connor Date: Wed, 3 Jun 2026 01:18:07 +0800 Subject: [PATCH 12/37] Fix Tencent deploy branch expansion (#235) --- .github/workflows/deploy-tencent.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index a9cbbcc..e17c9ab 100644 --- a/.github/workflows/deploy-tencent.yml +++ b/.github/workflows/deploy-tencent.yml @@ -52,9 +52,9 @@ jobs: } cd '${{ steps.target.outputs.app_dir }}' - retry 5 15 timeout 180s git fetch origin '${GITHUB_REF_NAME}' - git checkout '${GITHUB_REF_NAME}' - git reset --hard 'origin/${GITHUB_REF_NAME}' + retry 5 15 timeout 180s git fetch origin "$GITHUB_REF_NAME" + git checkout "$GITHUB_REF_NAME" + git reset --hard "origin/$GITHUB_REF_NAME" retry 3 15 npm ci set -a . /etc/meteorvoice/meteorvoice.env From 411672d19edd4fa238283ce97cc2dacd031c42f0 Mon Sep 17 00:00:00 2001 From: Connor Date: Wed, 3 Jun 2026 01:29:03 +0800 Subject: [PATCH 13/37] Keep Tencent PM2 deployment alive (#237) --- .github/workflows/deploy-tencent.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index e17c9ab..052af8b 100644 --- a/.github/workflows/deploy-tencent.yml +++ b/.github/workflows/deploy-tencent.yml @@ -60,9 +60,10 @@ jobs: . /etc/meteorvoice/meteorvoice.env set +a retry 2 15 npm run build + unset RUNNER_TRACKING_ID PORT='${{ steps.target.outputs.port }}' HOSTNAME=127.0.0.1 \ pm2 restart '${{ steps.target.outputs.pm2_name }}' --update-env || \ PORT='${{ steps.target.outputs.port }}' HOSTNAME=127.0.0.1 \ pm2 start npm --name '${{ steps.target.outputs.pm2_name }}' -- run start --workspace @meteorvoice/web -- --hostname 127.0.0.1 --port '${{ steps.target.outputs.port }}' pm2 save - curl -fsS 'http://127.0.0.1:${{ steps.target.outputs.port }}/' >/dev/null + retry 12 5 curl -fsS 'http://127.0.0.1:${{ steps.target.outputs.port }}/' >/dev/null From afedc60ae561fcd437c3b6607ff6bb66d2eafbe3 Mon Sep 17 00:00:00 2001 From: Connor Date: Wed, 3 Jun 2026 19:59:52 +0800 Subject: [PATCH 14/37] Support client session IDs on HTTP (#239) --- apps/web/components/VoiceSessionProvider.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/components/VoiceSessionProvider.tsx b/apps/web/components/VoiceSessionProvider.tsx index 83bcc6a..201bd16 100644 --- a/apps/web/components/VoiceSessionProvider.tsx +++ b/apps/web/components/VoiceSessionProvider.tsx @@ -105,11 +105,27 @@ interface PersistedVoiceSessionState { summary: string | null } +function createClientSessionId() { + const browserCrypto = globalThis.crypto + if (typeof browserCrypto?.randomUUID === 'function') return browserCrypto.randomUUID() + + if (typeof browserCrypto?.getRandomValues === 'function') { + const bytes = new Uint8Array(16) + browserCrypto.getRandomValues(bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` + } + + return `${Date.now()}-${Math.random().toString(16).slice(2)}` +} + function createDefaultPersistedState(): PersistedVoiceSessionState { return { scenarioKey: 'small-talk', accentKey: 'american', - snapshot: createInitialSnapshot(crypto.randomUUID()), + snapshot: createInitialSnapshot(createClientSessionId()), statusText: '', isSessionActive: false, isRoutePaused: false, @@ -975,7 +991,7 @@ export default function VoiceSessionProvider({ children }: { children: ReactNode return } - const nextSnapshot = createInitialSnapshot(crypto.randomUUID()) + const nextSnapshot = createInitialSnapshot(createClientSessionId()) snapshotRef.current = nextSnapshot setSnapshot(nextSnapshot) activeSessionRef.current = true From e00d3dcb88e57d639b33488cb2a6089ac2fc29b0 Mon Sep 17 00:00:00 2001 From: Connor Date: Wed, 3 Jun 2026 20:18:36 +0800 Subject: [PATCH 15/37] Correct Xunfei voice profile genders (#241) --- supabase/migrations/008_voice_profile_preferences.sql | 4 ++-- .../010_fix_xunfei_voice_profile_genders.sql | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/010_fix_xunfei_voice_profile_genders.sql diff --git a/supabase/migrations/008_voice_profile_preferences.sql b/supabase/migrations/008_voice_profile_preferences.sql index f639d28..af66039 100644 --- a/supabase/migrations/008_voice_profile_preferences.sql +++ b/supabase/migrations/008_voice_profile_preferences.sql @@ -46,8 +46,8 @@ insert into tts_voice_profiles (id, provider, provider_voice_id, display_name, display_name_zh, description, description_zh, locale, accent_key, accent_label, accent_region, gender, style, quality_tier, status, expires_at, sort_order) values ('mock:browser', 'mock', null, 'Browser voice', '浏览器声音', 'Local browser speech for development.', '用于本地开发的浏览器语音。', 'en', 'american', 'American', 'US', null, 'development', null, 'active', null, 10), - ('xunfei:x4_enus_catherine_profnews', 'xunfei', 'x4_enus_catherine_profnews', 'Catherine Professional News', 'Catherine 新闻女声', 'News-style coach voice.', '新闻风格教练声音。', 'en', 'american', 'American', 'US', 'male', 'professional news', 'featured', 'active', '2026-06-08T16:00:00.000Z', 200), - ('xunfei:x4_enus_ryan_assist', 'xunfei', 'x4_enus_ryan_assist', 'Ryan Assistant', 'Ryan 助手女声', 'Assistant-style coach voice.', '助手风格教练声音。', 'en', 'american', 'American', 'US', 'female', 'assistant', 'featured', 'active', '2026-06-08T16:00:00.000Z', 210), + ('xunfei:x4_enus_catherine_profnews', 'xunfei', 'x4_enus_catherine_profnews', 'Catherine Professional News', 'Catherine 新闻女声', 'News-style coach voice.', '新闻风格教练声音。', 'en', 'american', 'American', 'US', 'female', 'professional news', 'featured', 'active', '2026-06-08T16:00:00.000Z', 200), + ('xunfei:x4_enus_ryan_assist', 'xunfei', 'x4_enus_ryan_assist', 'Ryan Assistant', 'Ryan 助手男声', 'Assistant-style coach voice.', '助手风格教练声音。', 'en', 'american', 'American', 'US', 'male', 'assistant', 'featured', 'active', '2026-06-08T16:00:00.000Z', 210), ('xunfei:x4_lingxiaolu_en', 'xunfei', 'x4_lingxiaolu_en', 'Lingxiaolu English', '讯飞小露英语', 'Mandarin voice with English support.', '支持英语的普通话发音人。', 'zh', 'american', 'American', 'US', 'female', 'featured', 'featured', 'active', '2026-06-08T16:00:00.000Z', 220), ('xunfei:x4_yezi', 'xunfei', 'x4_yezi', 'Yezi', '讯飞叶子', 'Mandarin coach voice.', '普通话教练声音。', 'zh', 'american', 'American', 'US', 'female', 'featured', 'featured', 'active', null, 230), ('xunfei:x4_xiaoyan', 'xunfei', 'x4_xiaoyan', 'Xiaoyan', '讯飞小燕', 'Base Mandarin voice.', '基础普通话发音人。', 'zh', 'american', 'American', 'US', 'female', 'base', 'base', 'active', null, 240), diff --git a/supabase/migrations/010_fix_xunfei_voice_profile_genders.sql b/supabase/migrations/010_fix_xunfei_voice_profile_genders.sql new file mode 100644 index 0000000..949a233 --- /dev/null +++ b/supabase/migrations/010_fix_xunfei_voice_profile_genders.sql @@ -0,0 +1,10 @@ +update tts_voice_profiles +set gender = 'female', + updated_at = now() +where id = 'xunfei:x4_enus_catherine_profnews'; + +update tts_voice_profiles +set gender = 'male', + display_name_zh = 'Ryan 助手男声', + updated_at = now() +where id = 'xunfei:x4_enus_ryan_assist'; From a385939d1c93e88c73dac5fbdf32cc0a79935716 Mon Sep 17 00:00:00 2001 From: Connor Date: Wed, 3 Jun 2026 20:54:10 +0800 Subject: [PATCH 16/37] Harden mobile preference auth (#243) --- apps/mobile/src/App.tsx | 29 +++++++++++++++++--------- apps/mobile/src/mobileAuth.ts | 30 ++++++++++++++++++++++----- apps/mobile/src/mobilePreferences.ts | 16 +++++++++----- apps/web/app/api/preferences/route.ts | 2 +- packages/api-client/src/client.ts | 6 ++++++ packages/api-client/src/types.ts | 2 ++ packages/shared/src/i18n.ts | 2 ++ 7 files changed, 66 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 1d4fbf0..747f5f0 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -418,6 +418,7 @@ function AppInner() { const audio = useNativeSessionAudio(audioUrl, ttsSpeedRouting.playbackRate) const auth = useMobileAuth() const getAuthHeaders = auth.getAuthHeaders + const signOut = auth.signOut const prefSyncTimerRef = useRef | null>(null) const themeInitializedRef = useRef(false) const listeningStartMsRef = useRef(0) @@ -455,10 +456,13 @@ function AppInner() { ?? providerVoiceProfiles.find(profile => profile.status === 'active') const sessionAccentName = selectedVoiceProfile?.accentLabel ?? getAccentLabel(accent, locale) const sessionAccentRegion = selectedVoiceProfile?.accentRegion ?? getAccentRegion(accent, locale) + const tr = useCallback((key: string) => t[locale]?.[key] ?? t.en[key] ?? key, [locale]) + const handleUnauthorized = useCallback(() => signOut(tr('settings.auth_expired')), [signOut, tr]) const api = useMemo(() => createMeteorVoiceApiClient({ baseUrl: apiBaseUrl.trim(), headers: getAuthHeaders, - }), [apiBaseUrl, getAuthHeaders]) + onUnauthorized: handleUnauthorized, + }), [apiBaseUrl, getAuthHeaders, handleUnauthorized]) const applyThemeLocal = useCallback((k: Parameters[0]) => { setThemeLocal(k) }, [setThemeLocal]) @@ -469,8 +473,6 @@ function AppInner() { void SecureStore.setItemAsync('theme_set_at', now) void api.updatePreferences({ ui_theme: k }).catch(() => {}) }, [setThemeLocal, api]) - const tr = useCallback((key: string) => t[locale]?.[key] ?? t.en[key] ?? key, [locale]) - const clearResumeListeningTimer = useCallback(() => { if (!resumeListeningTimerRef.current) return clearTimeout(resumeListeningTimerRef.current) @@ -846,11 +848,13 @@ function AppInner() { messages, scenario: scenario.key, semanticCheck: auth.state === 'signed-in' ? async (t, ctx) => { + const authHeaders = await getAuthHeaders() const res = await fetch(`${baseUrl}/api/semantic-endpoint`, { method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-mobile', ...getAuthHeaders() }, + headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-mobile', ...authHeaders }, body: JSON.stringify({ transcript: t, messages: ctx.messages, scenario: ctx.scenario }), }) + if (res.status === 401) await handleUnauthorized() if (!res.ok) throw new Error('Semantic check failed') const data = await res.json() as { judgment: 'done' | 'thinking' } return data.judgment @@ -875,7 +879,7 @@ function AppInner() { pendingNativeTranscriptRef.current = '' logVoiceMetric('submit_turn_start', { chars: endpointTranscript.length }) void submitTurn(endpointTranscript) - }, [apiBaseUrl, audio.isPlaying, auth.state, getAuthHeaders, isSessionActive, logVoiceMetric, messages, scenario.key, snapshot.lastResponse, snapshot.state, submitTurn]) + }, [apiBaseUrl, audio.isPlaying, auth.state, getAuthHeaders, handleUnauthorized, isSessionActive, logVoiceMetric, messages, scenario.key, snapshot.lastResponse, snapshot.state, submitTurn]) const handleListeningEndedWithoutTranscript = useCallback(() => { if (!sessionActiveRef.current || !canListenOnRouteRef.current || busy || playbackActiveRef.current || audioPlayingRef.current) { @@ -1291,9 +1295,13 @@ function AppInner() { async function deleteSession(id: string) { setHistorySessions(prev => prev.map(s => s.id === id ? { ...s, status: 'deleted' } : s)) try { + const authHeaders = await getAuthHeaders() await fetch(`${apiBaseUrl.trim()}/api/session?id=${encodeURIComponent(id)}`, { method: 'DELETE', - headers: getAuthHeaders() as Record, + headers: authHeaders as Record, + }).then(res => { + if (res.status === 401) return handleUnauthorized() + return undefined }) } catch { // 静默失败 @@ -1425,6 +1433,7 @@ function AppInner() { void syncMobilePreferences({ apiBaseUrl: apiBaseUrl.trim(), getAuthHeaders: auth.getAuthHeaders, + onUnauthorized: handleUnauthorized, ttsSpeed: next, ttsProvider, defaultScenarioKey: selectedScenarioKey, @@ -1540,8 +1549,8 @@ function AppInner() { // 登录后自动拉取偏好 useEffect(() => { if (auth.state !== 'signed-in') return - void pullMobilePreferences(apiBaseUrl.trim(), auth.getAuthHeaders).then(applyPrefs) - }, [auth.state, apiBaseUrl, auth.getAuthHeaders, applyPrefs]) + void pullMobilePreferences(apiBaseUrl.trim(), auth.getAuthHeaders, handleUnauthorized).then(applyPrefs) + }, [auth.state, apiBaseUrl, auth.getAuthHeaders, applyPrefs, handleUnauthorized]) useEffect(() => { const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { @@ -1558,7 +1567,7 @@ function AppInner() { canListenOnRouteRef.current = true if (auth.state === 'signed-in') { - void pullMobilePreferences(apiBaseUrl.trim(), auth.getAuthHeaders).then(applyPrefs) + void pullMobilePreferences(apiBaseUrl.trim(), auth.getAuthHeaders, handleUnauthorized).then(applyPrefs) } else { void api.getPreferences().then(applyServerPreferences).catch(() => undefined) } @@ -1570,7 +1579,7 @@ function AppInner() { } }) return () => subscription.remove() - }, [api, apiBaseUrl, applyPrefs, applyServerPreferences, auth.getAuthHeaders, auth.state, busy, clearResumeListeningTimer]) + }, [api, apiBaseUrl, applyPrefs, applyServerPreferences, auth.getAuthHeaders, auth.state, busy, clearResumeListeningTimer, handleUnauthorized]) function playCorrection(text: string) { clearResumeListeningTimer() diff --git a/apps/mobile/src/mobileAuth.ts b/apps/mobile/src/mobileAuth.ts index bf4cc0f..819b9d0 100644 --- a/apps/mobile/src/mobileAuth.ts +++ b/apps/mobile/src/mobileAuth.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Platform, Settings } from 'react-native' import { createClient, type Session, type SupabaseClient, type User } from '@supabase/supabase-js' import * as SecureStore from 'expo-secure-store' @@ -15,9 +16,25 @@ export type MobileAuthState = ReturnType const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY +const authStorageKey = 'meteorvoice-mobile-supabase-auth' +const installMarkerKey = 'meteorvoice.installation_marker.v1' +const installMarkerValue = 'installed' + +let shouldClearPersistedAuth = false +if (Platform.OS === 'ios' && Settings.get(installMarkerKey) !== installMarkerValue) { + shouldClearPersistedAuth = true + Settings.set({ [installMarkerKey]: installMarkerValue }) +} const secureStorage = { - getItem: (key: string) => SecureStore.getItemAsync(key), + getItem: async (key: string) => { + if (shouldClearPersistedAuth && key === authStorageKey) { + shouldClearPersistedAuth = false + await SecureStore.deleteItemAsync(key) + return null + } + return SecureStore.getItemAsync(key) + }, setItem: (key: string, value: string) => SecureStore.setItemAsync(key, value), removeItem: (key: string) => SecureStore.deleteItemAsync(key), } @@ -37,6 +54,7 @@ export function useMobileAuth() { autoRefreshToken: true, detectSessionInUrl: false, persistSession: true, + storageKey: authStorageKey, storage: secureStorage, }, }) @@ -111,7 +129,7 @@ export function useMobileAuth() { } }, [client]) - const signOut = useCallback(async () => { + const signOut = useCallback(async (nextMessage: string | null = null) => { if (!client) return setState('loading') await client.auth.signOut() @@ -119,6 +137,7 @@ export function useMobileAuth() { setSession(null) setUser(null) setState('signed-out') + setMessage(nextMessage) }, [client]) const refreshSession = useCallback(async () => { @@ -166,9 +185,10 @@ export function useMobileAuth() { return Boolean(data.session?.access_token) }, [client]) - const getAuthHeaders = useCallback((): HeadersInit => ( - sessionRef.current?.access_token ? { Authorization: `Bearer ${sessionRef.current.access_token}` } : {} - ), []) + const getAuthHeaders = useCallback(async (): Promise => { + await refreshSession() + return sessionRef.current?.access_token ? { Authorization: `Bearer ${sessionRef.current.access_token}` } : {} + }, [refreshSession]) return { getAuthHeaders, diff --git a/apps/mobile/src/mobilePreferences.ts b/apps/mobile/src/mobilePreferences.ts index f00b268..4348993 100644 --- a/apps/mobile/src/mobilePreferences.ts +++ b/apps/mobile/src/mobilePreferences.ts @@ -13,7 +13,8 @@ export type XunfeiVoice = { type PrefInput = { apiBaseUrl: string - getAuthHeaders: () => HeadersInit + getAuthHeaders: () => HeadersInit | Promise + onUnauthorized?: () => void | Promise ttsProvider?: string ttsSpeed?: number defaultScenarioKey?: string @@ -25,7 +26,7 @@ type PrefInput = { // 内存中记录同步失败的 key,下次成功 sync 时重试 const pendingSyncKeys = new Set() -async function hasAuth(getAuthHeaders: () => HeadersInit) { +async function hasAuth(getAuthHeaders: () => HeadersInit | Promise) { const headers = await getAuthHeaders() return !!(headers as Record).Authorization } @@ -47,7 +48,11 @@ export async function syncMobilePreferences(input: PrefInput) { if (Object.keys(body).length === 0) return try { - const api = createMeteorVoiceApiClient({ baseUrl: input.apiBaseUrl, headers: input.getAuthHeaders }) + const api = createMeteorVoiceApiClient({ + baseUrl: input.apiBaseUrl, + headers: input.getAuthHeaders, + onUnauthorized: input.onUnauthorized, + }) await api.updatePreferences(body) // 成功后清除 pending pendingSyncKeys.clear() @@ -61,7 +66,8 @@ export async function syncMobilePreferences(input: PrefInput) { export async function pullMobilePreferences( apiBaseUrl: string, - getAuthHeaders: () => HeadersInit, + getAuthHeaders: () => HeadersInit | Promise, + onUnauthorized?: () => void | Promise, ): Promise<{ ttsProvider: string ttsSpeed: number @@ -82,7 +88,7 @@ export async function pullMobilePreferences( if (!authed) return null try { - const api = createMeteorVoiceApiClient({ baseUrl: apiBaseUrl, headers: getAuthHeaders }) + const api = createMeteorVoiceApiClient({ baseUrl: apiBaseUrl, headers: getAuthHeaders, onUnauthorized }) const raw = await api.getPreferences() pendingSyncKeys.clear() return { diff --git a/apps/web/app/api/preferences/route.ts b/apps/web/app/api/preferences/route.ts index f663780..a824ae7 100644 --- a/apps/web/app/api/preferences/route.ts +++ b/apps/web/app/api/preferences/route.ts @@ -3,7 +3,7 @@ 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 }) + const guard = guardApiRequest(request, { name: 'preferences_get', windowMs: 60_000, maxRequests: 30, requireClientHeader: true }) if (guard) return jsonApiResult(guard) const auth = await requireApiUser() if (auth) return jsonApiResult(auth) diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 84641d1..61d2aba 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -2,6 +2,7 @@ import type { ApiClientOptions, ApiErrorBody, ApiHeadersProvider, + ApiUnauthorizedHandler, CreateASRSessionRequest, CreateASRSessionResponse, CreateSessionRequest, @@ -43,11 +44,13 @@ export class MeteorVoiceApiClient { private readonly baseUrl: string private readonly fetchImpl: typeof fetch private readonly headers?: ApiHeadersProvider + private readonly onUnauthorized?: ApiUnauthorizedHandler constructor(options: ApiClientOptions = {}) { this.baseUrl = options.baseUrl?.replace(/\/$/, '') ?? '' this.fetchImpl = options.fetch ?? fetch this.headers = options.headers + this.onUnauthorized = options.onUnauthorized } getPreferences() { @@ -160,6 +163,9 @@ export class MeteorVoiceApiClient { if (!response.ok) { const message = isApiErrorBody(body) ? body.error : `Request failed with status ${response.status}` + if (response.status === 401) { + await this.onUnauthorized?.() + } throw new MeteorVoiceApiError(message, response.status, body) } diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts index 4737c3a..3cb8f21 100644 --- a/packages/api-client/src/types.ts +++ b/packages/api-client/src/types.ts @@ -15,11 +15,13 @@ import type { export type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise export type ApiHeadersProvider = HeadersInit | (() => HeadersInit | Promise) +export type ApiUnauthorizedHandler = () => void | Promise export type ApiClientOptions = { baseUrl?: string fetch?: FetchLike headers?: ApiHeadersProvider + onUnauthorized?: ApiUnauthorizedHandler } export type ApiErrorBody = { diff --git a/packages/shared/src/i18n.ts b/packages/shared/src/i18n.ts index 6386ef0..1194d55 100644 --- a/packages/shared/src/i18n.ts +++ b/packages/shared/src/i18n.ts @@ -149,6 +149,7 @@ export const t: Record> = { 'history.load_error': 'Failed to load sessions.', 'settings.title': 'Settings', 'settings.subtitle': 'Customize your practice environment.', + 'settings.auth_expired': 'Your login has expired. Please sign in again.', 'settings.theme': 'Theme', 'settings.theme_desc': 'Choose a visual theme for the app.', 'settings.theme_default_calm': 'Default Calm', @@ -404,6 +405,7 @@ export const t: Record> = { 'history.load_error': '加载失败。', 'settings.title': '设置', 'settings.subtitle': '自定义你的练习环境。', + 'settings.auth_expired': '登录已过期,请重新登录。', 'settings.theme': '主题', 'settings.theme_desc': '选择界面视觉主题。', 'settings.theme_default_calm': '默认柔和', From bbacddd005ab576330b4d188474634d31e7e8b52 Mon Sep 17 00:00:00 2001 From: Connor Date: Wed, 3 Jun 2026 21:51:55 +0800 Subject: [PATCH 17/37] Retry mobile settings data after auth (#245) --- apps/mobile/app.json | 10 +- .../ios/MeteorVoice.xcodeproj/project.pbxproj | 4 +- apps/mobile/ios/MeteorVoice/Info.plist | 14 +-- apps/mobile/src/App.tsx | 93 ++++++++++++------- packages/shared/src/i18n.ts | 2 + 5 files changed, 79 insertions(+), 44 deletions(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 6250956..c895acb 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -11,19 +11,23 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "com.tzcpa.meteorvoice", - "buildNumber": "2026053107", + "buildNumber": "2026060301", "deploymentTarget": "18.0", "infoPlist": { "NSMicrophoneUsageDescription": "MeteorVoice uses the microphone for voice practice sessions.", "UIBackgroundModes": [ "audio" ], - "NSSpeechRecognitionUsageDescription": "MeteorVoice uses speech recognition to turn your practice speech into text." + "NSSpeechRecognitionUsageDescription": "MeteorVoice uses speech recognition to turn your practice speech into text.", + "NSAppTransportSecurity": { + "NSAllowsArbitraryLoads": true, + "NSAllowsLocalNetworking": true + } } }, "android": { "package": "com.jcmeteor.meteorvoice", - "versionCode": 2026053107, + "versionCode": 2026060301, "permissions": [ "android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS", diff --git a/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj b/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj index b5c3261..beb72e1 100644 --- a/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj @@ -338,7 +338,7 @@ CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2026053107; + CURRENT_PROJECT_VERSION = 2026060301; DEVELOPMENT_TEAM = LGGMBJG4VB; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -376,7 +376,7 @@ CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2026053107; + CURRENT_PROJECT_VERSION = 2026060301; DEVELOPMENT_TEAM = LGGMBJG4VB; INFOPLIST_FILE = MeteorVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; diff --git a/apps/mobile/ios/MeteorVoice/Info.plist b/apps/mobile/ios/MeteorVoice/Info.plist index ef784a1..1aa8052 100644 --- a/apps/mobile/ios/MeteorVoice/Info.plist +++ b/apps/mobile/ios/MeteorVoice/Info.plist @@ -38,13 +38,13 @@ 12.0 LSRequiresIPhoneOS - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSAllowsLocalNetworking - - + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + NSFaceIDUsageDescription Allow $(PRODUCT_NAME) to access your Face ID biometric data. NSMicrophoneUsageDescription diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 747f5f0..e36892c 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -1268,6 +1268,10 @@ function AppInner() { const loadHistory = useCallback(async () => { if (historyLoading) return + if (auth.state !== 'signed-in') { + setHistoryError(tr('history.auth_required')) + return + } setHistoryLoading(true) setHistoryError(null) try { @@ -1284,13 +1288,18 @@ function AppInner() { } finally { setHistoryLoading(false) } - }, [api, historyLoading]) + }, [api, auth.state, historyLoading, tr]) useEffect(() => { - if (activeTab !== 'history' || historyAutoLoadRef.current) return + if (activeTab !== 'history') return + if (auth.state !== 'signed-in') { + historyAutoLoadRef.current = false + return + } + if (historyAutoLoadRef.current) return historyAutoLoadRef.current = true void loadHistory() - }, [activeTab, loadHistory]) + }, [activeTab, auth.state, loadHistory]) async function deleteSession(id: string) { setHistorySessions(prev => prev.map(s => s.id === id ? { ...s, status: 'deleted' } : s)) @@ -1323,6 +1332,27 @@ function AppInner() { } } + const loadSessionSttProviders = useCallback(async () => { + try { + const result = await api.listASRProviders() + 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) + } + }, [api, logVoiceMetric, sessionSttProvider]) + const loadPreferences = useCallback(async () => { if (settingsLoading) return setSettingsLoading(true) @@ -1352,11 +1382,23 @@ function AppInner() { } }, [api, setLocale, settingsLoading, tr]) + const reloadSettingsData = useCallback(() => { + void loadPreferences() + const timer = setTimeout(() => { + void loadSessionSttProviders() + }, 0) + return () => clearTimeout(timer) + }, [loadPreferences, loadSessionSttProviders]) + useEffect(() => { - if (activeTab !== 'settings' || auth.state !== 'signed-in' || settingsAutoLoadRef.current) return + if (auth.state !== 'signed-in') { + settingsAutoLoadRef.current = false + return + } + if (activeTab !== 'settings' || settingsAutoLoadRef.current) return settingsAutoLoadRef.current = true - void loadPreferences() - }, [activeTab, auth.state, loadPreferences]) + return reloadSettingsData() + }, [activeTab, auth.state, reloadSettingsData]) async function saveProvider(provider: string) { setTtsProvider(provider) @@ -1516,41 +1558,28 @@ function AppInner() { }, [setLocale]) useEffect(() => { + if (auth.state !== 'signed-in') return void api.getPreferences() .then(applyServerPreferences) .catch(() => undefined) - }, [api, applyServerPreferences]) + }, [api, applyServerPreferences, auth.state]) 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]) + const timer = setTimeout(() => { + void loadSessionSttProviders() + }, 0) + return () => clearTimeout(timer) + }, [loadSessionSttProviders]) // 登录后自动拉取偏好 useEffect(() => { if (auth.state !== 'signed-in') return void pullMobilePreferences(apiBaseUrl.trim(), auth.getAuthHeaders, handleUnauthorized).then(applyPrefs) - }, [auth.state, apiBaseUrl, auth.getAuthHeaders, applyPrefs, handleUnauthorized]) + const timer = setTimeout(() => { + void loadSessionSttProviders() + }, 0) + return () => clearTimeout(timer) + }, [auth.state, apiBaseUrl, auth.getAuthHeaders, applyPrefs, handleUnauthorized, loadSessionSttProviders]) useEffect(() => { const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { @@ -1680,7 +1709,7 @@ function AppInner() { onSetSessionSttProvider={setSessionSttProvider} onAdjustSpeed={adjustSpeed} onSavePracticePreferences={() => void savePracticePreferences()} - onLoadPreferences={() => void loadPreferences()} + onLoadPreferences={() => { reloadSettingsData() }} onSelectVoiceProfile={profile => void selectVoiceProfile(profile)} onSetEmail={setEmail} onSetPassword={setPassword} diff --git a/packages/shared/src/i18n.ts b/packages/shared/src/i18n.ts index 1194d55..2242fab 100644 --- a/packages/shared/src/i18n.ts +++ b/packages/shared/src/i18n.ts @@ -147,6 +147,7 @@ export const t: Record> = { 'history.delete_confirm': 'Delete this session? This can be undone from the database.', 'history.deleted': 'Session deleted.', 'history.load_error': 'Failed to load sessions.', + 'history.auth_required': 'Please sign in to view history.', 'settings.title': 'Settings', 'settings.subtitle': 'Customize your practice environment.', 'settings.auth_expired': 'Your login has expired. Please sign in again.', @@ -403,6 +404,7 @@ export const t: Record> = { 'history.delete_confirm': '确定删除此练习记录?可从数据库中恢复。', 'history.deleted': '记录已删除。', 'history.load_error': '加载失败。', + 'history.auth_required': '请先登录后再查看历史记录。', 'settings.title': '设置', 'settings.subtitle': '自定义你的练习环境。', 'settings.auth_expired': '登录已过期,请重新登录。', From 6f25dc2026ea6cd19e427e288a28dac3ec29f912 Mon Sep 17 00:00:00 2001 From: Connor Date: Wed, 3 Jun 2026 23:15:36 +0800 Subject: [PATCH 18/37] Fix iOS ATS HTTP exception (#247) --- apps/mobile/app.json | 7 +++---- apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj | 4 ++-- apps/mobile/ios/MeteorVoice/Info.plist | 2 -- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index c895acb..41120aa 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -11,7 +11,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "com.tzcpa.meteorvoice", - "buildNumber": "2026060301", + "buildNumber": "2026060302", "deploymentTarget": "18.0", "infoPlist": { "NSMicrophoneUsageDescription": "MeteorVoice uses the microphone for voice practice sessions.", @@ -20,14 +20,13 @@ ], "NSSpeechRecognitionUsageDescription": "MeteorVoice uses speech recognition to turn your practice speech into text.", "NSAppTransportSecurity": { - "NSAllowsArbitraryLoads": true, - "NSAllowsLocalNetworking": true + "NSAllowsArbitraryLoads": true } } }, "android": { "package": "com.jcmeteor.meteorvoice", - "versionCode": 2026060301, + "versionCode": 2026060302, "permissions": [ "android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS", diff --git a/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj b/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj index beb72e1..8fa5641 100644 --- a/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj @@ -338,7 +338,7 @@ CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2026060301; + CURRENT_PROJECT_VERSION = 2026060302; DEVELOPMENT_TEAM = LGGMBJG4VB; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -376,7 +376,7 @@ CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2026060301; + CURRENT_PROJECT_VERSION = 2026060302; DEVELOPMENT_TEAM = LGGMBJG4VB; INFOPLIST_FILE = MeteorVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; diff --git a/apps/mobile/ios/MeteorVoice/Info.plist b/apps/mobile/ios/MeteorVoice/Info.plist index 1aa8052..1943f9f 100644 --- a/apps/mobile/ios/MeteorVoice/Info.plist +++ b/apps/mobile/ios/MeteorVoice/Info.plist @@ -42,8 +42,6 @@ NSAllowsArbitraryLoads - NSAllowsLocalNetworking - NSFaceIDUsageDescription Allow $(PRODUCT_NAME) to access your Face ID biometric data. From feb2db6b2324b8aab96483844a84ec5026813744 Mon Sep 17 00:00:00 2001 From: Connor Date: Thu, 4 Jun 2026 00:02:05 +0800 Subject: [PATCH 19/37] Stabilize settings sync and app feedback --- apps/mobile/src/App.tsx | 154 ++++++++-------- .../src/components/AppFeedbackOverlay.tsx | 74 ++++++++ apps/mobile/src/mobilePreferences.ts | 8 +- apps/mobile/src/screens/SettingsScreen.tsx | 17 +- apps/web/app/history/page.tsx | 44 ++++- apps/web/app/layout.tsx | 2 + apps/web/app/settings/page.tsx | 167 ++++++++++++------ apps/web/components/AppFeedbackPresenter.tsx | 48 +++++ apps/web/components/VoiceSessionProvider.tsx | 3 +- packages/shared/src/feedback.ts | 104 +++++++++++ packages/shared/src/i18n.ts | 4 + packages/shared/src/index.ts | 1 + 12 files changed, 477 insertions(+), 149 deletions(-) create mode 100644 apps/mobile/src/components/AppFeedbackOverlay.tsx create mode 100644 apps/web/components/AppFeedbackPresenter.tsx create mode 100644 packages/shared/src/feedback.ts diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index e36892c..ad7d18d 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -14,7 +14,6 @@ import { formatApiRequestError, type CreateASRSessionResponse, type HistorySession, - type PreferencesResponse, type SessionTurnDto, } from '@meteorvoice/api-client' import { @@ -49,6 +48,9 @@ import { getTTSSpeedRouting, scenarios, t, + appFeedback, + displayErrorFeedback, + type AppFeedbackState, type ConversationMessage, type ConversationResponse, type Locale, @@ -59,7 +61,7 @@ import * as SecureStore from 'expo-secure-store' import { useMobileAuth } from './mobileAuth' import { useNativeSessionAudio } from './nativeAudio' import { useNativeSpeech } from './nativeSpeech' -import { pullMobilePreferences, syncMobilePreferences, type XunfeiVoice } from './mobilePreferences' +import { syncMobilePreferences, type XunfeiVoice } from './mobilePreferences' import { getDefaultApiBaseUrl, getDisplayAppVersion } from './mobileConfig' import { addPcmFrameListener, @@ -74,6 +76,7 @@ import { SessionScreen } from './screens/SessionScreen' import { HomeScreen } from './screens/HomeScreen' import { HistoryScreen } from './screens/HistoryScreen' import { SettingsScreen } from './screens/SettingsScreen' +import { AppFeedbackOverlay } from './components/AppFeedbackOverlay' const defaultApiBaseUrl = getDefaultApiBaseUrl() const appVersion = getDisplayAppVersion() @@ -390,6 +393,7 @@ function AppInner() { const [selectedHistoryTurns, setSelectedHistoryTurns] = useState([]) const [settingsLoading, setSettingsLoading] = useState(false) const [settingsMessage, setSettingsMessage] = useState(null) + const [activeFeedback, setActiveFeedback] = useState(() => appFeedback.getFeedback()) const [ttsProvider, setTtsProvider] = useState('mock') const [availableProviders, setAvailableProviders] = useState(['mock']) const [sessionSttProvider, setSessionSttProviderState] = useState('native') @@ -447,6 +451,7 @@ function AppInner() { const resumeListeningTimerRef = useRef | null>(null) const historyAutoLoadRef = useRef(false) const settingsAutoLoadRef = useRef(false) + const settingsRequestRef = useRef(0) const scenario = scenarios.find(item => item.key === selectedScenarioKey) ?? scenarios[0] const accent = accentProfiles.find(item => item.key === selectedAccentKey) ?? accentProfiles[0] @@ -457,7 +462,10 @@ function AppInner() { const sessionAccentName = selectedVoiceProfile?.accentLabel ?? getAccentLabel(accent, locale) const sessionAccentRegion = selectedVoiceProfile?.accentRegion ?? getAccentRegion(accent, locale) const tr = useCallback((key: string) => t[locale]?.[key] ?? t.en[key] ?? key, [locale]) - const handleUnauthorized = useCallback(() => signOut(tr('settings.auth_expired')), [signOut, tr]) + const handleUnauthorized = useCallback(() => { + if (auth.state !== 'signed-in') return signOut(null) + return signOut(tr('settings.auth_expired')) + }, [auth.state, signOut, tr]) const api = useMemo(() => createMeteorVoiceApiClient({ baseUrl: apiBaseUrl.trim(), headers: getAuthHeaders, @@ -770,6 +778,7 @@ function AppInner() { presentation: 'banner', }) logVoiceMetric('mobile_session_request_error', requestError.logData) + displayErrorFeedback(requestError, 'mobile_session_submit') setStatus(requestError.displayMessage) if (sessionActiveRef.current && canListenOnRouteRef.current) { scheduleResumeListening(900) @@ -791,7 +800,7 @@ function AppInner() { .filter(Boolean) .join(' ') - if (!isSessionActive) { + if (!sessionActiveRef.current) { logVoiceMetric('transcript_ignored_inactive', { chars: transcript.length }) setStatus('session.status.speech_captured') return @@ -879,7 +888,7 @@ function AppInner() { pendingNativeTranscriptRef.current = '' logVoiceMetric('submit_turn_start', { chars: endpointTranscript.length }) void submitTurn(endpointTranscript) - }, [apiBaseUrl, audio.isPlaying, auth.state, getAuthHeaders, handleUnauthorized, isSessionActive, logVoiceMetric, messages, scenario.key, snapshot.lastResponse, snapshot.state, submitTurn]) + }, [apiBaseUrl, audio.isPlaying, auth.state, getAuthHeaders, handleUnauthorized, logVoiceMetric, messages, scenario.key, snapshot.lastResponse, snapshot.state, submitTurn]) const handleListeningEndedWithoutTranscript = useCallback(() => { if (!sessionActiveRef.current || !canListenOnRouteRef.current || busy || playbackActiveRef.current || audioPlayingRef.current) { @@ -1353,12 +1362,32 @@ function AppInner() { } }, [api, logVoiceMetric, sessionSttProvider]) - const loadPreferences = useCallback(async () => { - if (settingsLoading) return + useEffect(() => appFeedback.subscribe(setActiveFeedback), []) + + useEffect(() => { + if (settingsLoading) { + appFeedback.show({ + message: tr('settings.syncing'), + variant: 'hud', + source: 'settings', + }) + return + } + appFeedback.hide('settings') + }, [settingsLoading, tr]) + + const loadPreferences = useCallback(async (options: { force?: boolean; successMessage?: string } = {}) => { + if (settingsLoading && !options.force) return + if (auth.state !== 'signed-in') { + setSettingsMessage(tr('settings.auth_required')) + return + } + const requestId = ++settingsRequestRef.current setSettingsLoading(true) setSettingsMessage(null) try { const preferences = await api.getPreferences() + if (requestId !== settingsRequestRef.current) return setLocale(preferences.locale === 'zh' ? 'zh' : 'en') setTtsProvider(preferences.tts_provider ?? 'mock') setAvailableProviders(preferences.available_providers?.length ? preferences.available_providers : ['mock']) @@ -1370,7 +1399,17 @@ function AppInner() { if (preferences.default_scenario_key) setSelectedScenarioKey(preferences.default_scenario_key) const profile = preferences.voice_profiles?.find(item => item.id === preferences.selected_voice_profile_id) if (profile) setSelectedAccentKey(profile.accentKey) - setSettingsMessage(tr('session.status.preferences_loaded')) + if (preferences.ui_theme && !themeInitializedRef.current) { + themeInitializedRef.current = true + void SecureStore.getItemAsync('theme_set_at').then(localSetAt => { + const serverTs = new Date(preferences.ui_theme_updated_at ?? new Date(0).toISOString()).getTime() + const localTs = localSetAt ? new Date(localSetAt).getTime() : 0 + if (serverTs >= localTs) { + applyThemeLocal(preferences.ui_theme as Parameters[0]) + } + }) + } + setSettingsMessage(options.successMessage ?? tr('session.status.preferences_loaded')) } catch (error) { const requestError = formatApiRequestError(error, { context: 'mobile_preferences_load', @@ -1378,9 +1417,11 @@ function AppInner() { }) setSettingsMessage(requestError.displayMessage) } finally { - setSettingsLoading(false) + if (requestId === settingsRequestRef.current) { + setSettingsLoading(false) + } } - }, [api, setLocale, settingsLoading, tr]) + }, [api, applyThemeLocal, auth.state, setLocale, settingsLoading, tr]) const reloadSettingsData = useCallback(() => { void loadPreferences() @@ -1401,6 +1442,7 @@ function AppInner() { }, [activeTab, auth.state, reloadSettingsData]) async function saveProvider(provider: string) { + settingsRequestRef.current += 1 setTtsProvider(provider) setAudioUrl(null) playbackEndedAtMsRef.current = null @@ -1413,18 +1455,12 @@ function AppInner() { } try { - const result = await api.updatePreferences({ + await api.updatePreferences({ tts_provider: provider, default_scenario_key: selectedScenarioKey, tts_speed: ttsSpeed, }) - setTtsProvider(result.tts_provider) - setTtsSpeed(result.tts_speed) - if (result.voice_profiles) setVoiceProfiles(result.voice_profiles) - if (result.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(result.selected_voice_profile_id) - const profile = result.voice_profiles?.find(item => item.id === result.selected_voice_profile_id) - if (profile) setSelectedAccentKey(profile.accentKey) - setSettingsMessage(tr('session.status.preferences_saved')) + await loadPreferences({ force: true, successMessage: tr('session.status.preferences_saved') }) } catch (error) { const requestError = formatApiRequestError(error, { context: 'mobile_preferences_save_provider', @@ -1437,6 +1473,7 @@ function AppInner() { } async function savePracticePreferences() { + settingsRequestRef.current += 1 setSettingsLoading(true) setSettingsMessage(null) if (auth.state !== 'signed-in') { @@ -1446,16 +1483,12 @@ function AppInner() { } try { - const result = await api.updatePreferences({ + await api.updatePreferences({ tts_provider: ttsProvider, default_scenario_key: selectedScenarioKey, tts_speed: ttsSpeed, }) - setTtsProvider(result.tts_provider) - setTtsSpeed(result.tts_speed) - if (result.voice_profiles) setVoiceProfiles(result.voice_profiles) - if (result.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(result.selected_voice_profile_id) - setSettingsMessage(tr('session.status.practice_defaults_saved')) + await loadPreferences({ force: true, successMessage: tr('session.status.practice_defaults_saved') }) } catch (error) { const requestError = formatApiRequestError(error, { context: 'mobile_preferences_save_practice', @@ -1479,6 +1512,10 @@ function AppInner() { ttsSpeed: next, ttsProvider, defaultScenarioKey: selectedScenarioKey, + }).then(saved => { + if (saved) { + void loadPreferences({ force: true, successMessage: tr('session.status.preferences_saved') }) + } }) }, 600) return next @@ -1487,6 +1524,7 @@ function AppInner() { async function selectVoiceProfile(profile: VoiceProfile) { if (profile.status !== 'active') return + settingsRequestRef.current += 1 setAudioUrl(null) playbackEndedAtMsRef.current = null setSelectedVoiceProfileId(profile.id) @@ -1497,11 +1535,8 @@ function AppInner() { if (auth.state !== 'signed-in') return try { - const result = await api.updatePreferences({ selected_voice_profile_id: profile.id }) - setTtsProvider(result.tts_provider) - setTtsVoiceId(result.tts_voice_id) - setSelectedVoiceProfileId(result.selected_voice_profile_id) - if (result.voice_profiles) setVoiceProfiles(result.voice_profiles) + await api.updatePreferences({ selected_voice_profile_id: profile.id }) + await loadPreferences({ force: true, successMessage: tr('session.status.preferences_saved') }) } catch (error) { const requestError = formatApiRequestError(error, { context: 'mobile_preferences_select_voice_profile', @@ -1518,52 +1553,6 @@ function AppInner() { if (success) setPassword('') } - const applyPrefs = useCallback((prefs: Awaited>) => { - if (!prefs) return - setTtsProvider(prefs.ttsProvider) - setTtsSpeed(prefs.ttsSpeed) - setAvailableProviders(prefs.availableProviders) - setTtsVoiceId(prefs.ttsVoiceId) - setVoiceProfiles(prefs.voiceProfiles) - setSelectedVoiceProfileId(prefs.selectedVoiceProfileId) - if (prefs.xunfeiVoices.length > 0) setXunfeiVoices(prefs.xunfeiVoices) - if (prefs.defaultScenarioKey) setSelectedScenarioKey(prefs.defaultScenarioKey) - const profile = prefs.voiceProfiles.find(item => item.id === prefs.selectedVoiceProfileId) - if (profile) setSelectedAccentKey(profile.accentKey) - if (prefs.locale === 'zh' || prefs.locale === 'en') setLocale(prefs.locale) - if (prefs.uiTheme && !themeInitializedRef.current) { - themeInitializedRef.current = true - void SecureStore.getItemAsync('theme_set_at').then(localSetAt => { - const serverTs = new Date(prefs.uiThemeUpdatedAt).getTime() - const localTs = localSetAt ? new Date(localSetAt).getTime() : 0 - if (serverTs >= localTs) { - applyThemeLocal(prefs.uiTheme as Parameters[0]) - } - }) - } - }, [applyThemeLocal, setLocale]) - - const applyServerPreferences = useCallback((preferences: PreferencesResponse) => { - setTtsProvider(preferences.tts_provider ?? 'mock') - setAvailableProviders(preferences.available_providers?.length ? preferences.available_providers : ['mock']) - setTtsSpeed(preferences.tts_speed ?? 1) - if (preferences.tts_voice_id !== undefined) setTtsVoiceId(preferences.tts_voice_id) - if (preferences.voice_profiles) setVoiceProfiles(preferences.voice_profiles) - if (preferences.selected_voice_profile_id !== undefined) setSelectedVoiceProfileId(preferences.selected_voice_profile_id) - if (preferences.xunfei_voices?.configured) setXunfeiVoices(preferences.xunfei_voices.configured) - if (preferences.default_scenario_key) setSelectedScenarioKey(preferences.default_scenario_key) - const profile = preferences.voice_profiles?.find(item => item.id === preferences.selected_voice_profile_id) - if (profile) setSelectedAccentKey(profile.accentKey) - if (preferences.locale === 'zh' || preferences.locale === 'en') setLocale(preferences.locale) - }, [setLocale]) - - useEffect(() => { - if (auth.state !== 'signed-in') return - void api.getPreferences() - .then(applyServerPreferences) - .catch(() => undefined) - }, [api, applyServerPreferences, auth.state]) - useEffect(() => { const timer = setTimeout(() => { void loadSessionSttProviders() @@ -1574,12 +1563,12 @@ function AppInner() { // 登录后自动拉取偏好 useEffect(() => { if (auth.state !== 'signed-in') return - void pullMobilePreferences(apiBaseUrl.trim(), auth.getAuthHeaders, handleUnauthorized).then(applyPrefs) const timer = setTimeout(() => { + void loadPreferences() void loadSessionSttProviders() }, 0) return () => clearTimeout(timer) - }, [auth.state, apiBaseUrl, auth.getAuthHeaders, applyPrefs, handleUnauthorized, loadSessionSttProviders]) + }, [auth.state, loadPreferences, loadSessionSttProviders]) useEffect(() => { const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => { @@ -1596,9 +1585,7 @@ function AppInner() { canListenOnRouteRef.current = true if (auth.state === 'signed-in') { - void pullMobilePreferences(apiBaseUrl.trim(), auth.getAuthHeaders, handleUnauthorized).then(applyPrefs) - } else { - void api.getPreferences().then(applyServerPreferences).catch(() => undefined) + void loadPreferences() } if (sessionActiveRef.current && !busy && !playbackActiveRef.current && !audioPlayingRef.current) { @@ -1608,7 +1595,7 @@ function AppInner() { } }) return () => subscription.remove() - }, [api, apiBaseUrl, applyPrefs, applyServerPreferences, auth.getAuthHeaders, auth.state, busy, clearResumeListeningTimer, handleUnauthorized]) + }, [auth.state, busy, clearResumeListeningTimer, loadPreferences]) function playCorrection(text: string) { clearResumeListeningTimer() @@ -1740,7 +1727,10 @@ function AppInner() { return ( - {renderScreen()} + + {renderScreen()} + + {(['home', 'session', 'history', 'settings'] as Tab[]).map(tab => ( diff --git a/apps/mobile/src/components/AppFeedbackOverlay.tsx b/apps/mobile/src/components/AppFeedbackOverlay.tsx new file mode 100644 index 0000000..f7b9769 --- /dev/null +++ b/apps/mobile/src/components/AppFeedbackOverlay.tsx @@ -0,0 +1,74 @@ +import { useEffect } from 'react' +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native' +import { hideAppFeedback, type AppFeedbackState } from '@meteorvoice/shared' +import { useTheme } from '../ThemeProvider' + +type Props = { + feedback: AppFeedbackState | null +} + +export function AppFeedbackOverlay({ feedback }: Props) { + const { C } = useTheme() + useEffect(() => { + if (!feedback?.active || !feedback.autoDismissMs) return + const timer = setTimeout(() => { + hideAppFeedback(feedback.source) + }, feedback.autoDismissMs) + return () => clearTimeout(timer) + }, [feedback?.active, feedback?.autoDismissMs, feedback?.source]) + + if (!feedback?.active) return null + + const variant = feedback.variant ?? 'hud' + const blocksInteraction = feedback.blocksInteraction ?? true + const styles = StyleSheet.create({ + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: variant === 'bar' ? 'transparent' : 'rgba(0,0,0,0.28)', + alignItems: 'center', + justifyContent: variant === 'bar' ? 'flex-start' : 'center', + paddingHorizontal: 24, + paddingTop: 18, + }, + surface: { + minWidth: variant === 'bar' ? '100%' : undefined, + backgroundColor: C.surface, + borderRadius: variant === 'bar' ? 8 : 10, + borderWidth: 1, + borderColor: C.border, + alignItems: 'center', + flexDirection: variant === 'panel' ? 'column' : 'row', + gap: 10, + paddingHorizontal: 18, + paddingVertical: 14, + }, + message: { + color: C.textPrimary, + fontSize: 14, + fontWeight: '700', + textAlign: 'center', + }, + title: { + color: C.textPrimary, + fontSize: 15, + fontWeight: '800', + textAlign: 'center', + }, + textStack: { + gap: 3, + flexShrink: 1, + }, + }) + + return ( + + + + + {feedback.title ? {feedback.title} : null} + {feedback.message} + + + + ) +} diff --git a/apps/mobile/src/mobilePreferences.ts b/apps/mobile/src/mobilePreferences.ts index 4348993..a19cb77 100644 --- a/apps/mobile/src/mobilePreferences.ts +++ b/apps/mobile/src/mobilePreferences.ts @@ -32,10 +32,10 @@ async function hasAuth(getAuthHeaders: () => HeadersInit | Promise) } export async function syncMobilePreferences(input: PrefInput) { - if (!input.apiBaseUrl) return + if (!input.apiBaseUrl) return false const authed = await hasAuth(input.getAuthHeaders) - if (!authed) return + if (!authed) return false const body: Record = {} if (input.ttsProvider !== undefined) body.tts_provider = input.ttsProvider @@ -45,7 +45,7 @@ export async function syncMobilePreferences(input: PrefInput) { if (input.selectedVoiceProfileId !== undefined) body.selected_voice_profile_id = input.selectedVoiceProfileId if (input.uiTheme !== undefined) body.ui_theme = input.uiTheme - if (Object.keys(body).length === 0) return + if (Object.keys(body).length === 0) return false try { const api = createMeteorVoiceApiClient({ @@ -56,11 +56,13 @@ export async function syncMobilePreferences(input: PrefInput) { await api.updatePreferences(body) // 成功后清除 pending pendingSyncKeys.clear() + return true } catch { // 静默失败,记录待同步项(应用重启后下次 API 拉取会自动覆盖) for (const key of Object.keys(body)) { pendingSyncKeys.add(key) } + return false } } diff --git a/apps/mobile/src/screens/SettingsScreen.tsx b/apps/mobile/src/screens/SettingsScreen.tsx index cf4e05c..0df7658 100644 --- a/apps/mobile/src/screens/SettingsScreen.tsx +++ b/apps/mobile/src/screens/SettingsScreen.tsx @@ -207,7 +207,12 @@ export function SettingsScreen({ {availableProviders.map(p => ( - onSaveProvider(p)} style={[styles.chip, ttsProvider === p && styles.chipActive]}> + onSaveProvider(p)} + disabled={settingsLoading} + style={[styles.chip, ttsProvider === p && styles.chipActive, settingsLoading && styles.chipDisabled]} + > {tr(`settings.tts_provider_${p}`) !== `settings.tts_provider_${p}` ? tr(`settings.tts_provider_${p}`) : p} @@ -225,7 +230,8 @@ export function SettingsScreen({ onSetSessionSttProvider(provider)} - style={[styles.chip, sessionSttProvider === provider && styles.chipActive]} + disabled={settingsLoading} + style={[styles.chip, sessionSttProvider === provider && styles.chipActive, settingsLoading && styles.chipDisabled]} > {tr(`settings.session_stt_provider_${provider}`)} @@ -265,6 +271,7 @@ export function SettingsScreen({ !unavailable && onSelectVoiceProfile(profile)} + disabled={settingsLoading || unavailable} style={[styles.voiceCatalogChip, active && styles.chipActive, unavailable && styles.chipDisabled]} > {voiceProfileName(profile)} @@ -288,18 +295,18 @@ export function SettingsScreen({ {tr('settings.tts_speed')} - onAdjustSpeed(-0.1)} style={styles.speedBtn}> + onAdjustSpeed(-0.1)} disabled={settingsLoading} style={[styles.speedBtn, settingsLoading && styles.chipDisabled]}> - onAdjustSpeed(0.1)} style={styles.speedBtn}> + onAdjustSpeed(0.1)} disabled={settingsLoading} style={[styles.speedBtn, settingsLoading && styles.chipDisabled]}> + {ttsSpeed.toFixed(1)}× - + {tr('settings.save')} diff --git a/apps/web/app/history/page.tsx b/apps/web/app/history/page.tsx index e09e570..37fd4b5 100644 --- a/apps/web/app/history/page.tsx +++ b/apps/web/app/history/page.tsx @@ -6,6 +6,7 @@ import { Card, CardContent } from '@/components/ui/card' import { scenarios, findAccentByKeyOrName, findScenarioByKeyOrName, getAccentLabel, getScenarioLabel } from '@/lib/scenarios' import { flushPendingPreferences } from '@/lib/tts-speed' import { formatApiRequestError, readApiJsonResponse } from '@meteorvoice/api-client' +import { displayErrorFeedback, hideAppFeedback, showAppFeedback } from '@meteorvoice/shared' interface HistorySession { id: string @@ -35,6 +36,8 @@ interface TurnData { } const PAGE_SIZE = 20 +const historyFeedbackSource = 'web_history' +const historyErrorFeedbackSource = 'web_history_error' export default function HistoryPage() { const { locale } = useLocale() @@ -51,6 +54,8 @@ export default function HistoryPage() { const [turnsLoading, setTurnsLoading] = useState(false) const [turnsError, setTurnsError] = useState(null) const [deletingId, setDeletingId] = useState(null) + const historyBusy = loading || loadingMore || turnsLoading || Boolean(deletingId) + const historyLoadingMessage = t('history.loading') function statusLabel(status: string) { const key = `history.status.${status}` @@ -82,6 +87,19 @@ export default function HistoryPage() { return readApiJsonResponse<{ sessions: HistorySession[]; hasMore: boolean }>(res, 'History request failed') }, []) + useEffect(() => { + if (historyBusy) { + showAppFeedback({ + source: historyFeedbackSource, + message: historyLoadingMessage, + variant: 'hud', + blocksInteraction: true, + }) + return + } + hideAppFeedback(historyFeedbackSource) + }, [historyBusy, historyLoadingMessage]) + // 首次加载 useEffect(() => { void flushPendingPreferences() @@ -96,10 +114,11 @@ export default function HistoryPage() { } }) .catch(err => { - setError(formatApiRequestError(err, { + const requestError = formatApiRequestError(err, { context: 'web_history_list', presentation: 'inline', - }).displayMessage) + }) + setError(requestError.displayMessage) }) .finally(() => setLoading(false)) }, [filterScenario, loadSessions, t]) @@ -112,8 +131,12 @@ export default function HistoryPage() { const data = await loadSessions(sessions.length, filterScenario) setSessions(prev => [...prev, ...(data.sessions ?? [])]) setHasMore(data.hasMore) - } catch { - // 静默失败 + } catch (err) { + const requestError = formatApiRequestError(err, { + context: 'web_history_load_more', + presentation: 'banner', + }) + displayErrorFeedback(requestError, historyErrorFeedbackSource) } finally { setLoadingMore(false) } @@ -135,10 +158,11 @@ export default function HistoryPage() { const data = await readApiJsonResponse<{ turns: TurnData[] }>(res, 'History turns request failed') setTurns(data.turns ?? []) } catch (err) { - setTurnsError(formatApiRequestError(err, { + const requestError = formatApiRequestError(err, { context: 'web_history_turns', presentation: 'inline', - }).displayMessage) + }) + setTurnsError(requestError.displayMessage) } finally { setTurnsLoading(false) } @@ -153,8 +177,12 @@ export default function HistoryPage() { if (res.ok) { setSessions(prev => prev.map(s => s.id === id ? { ...s, status: 'deleted' } : s)) } - } catch { - // 静默失败 + } catch (err) { + const requestError = formatApiRequestError(err, { + context: 'web_history_delete', + presentation: 'banner', + }) + displayErrorFeedback(requestError, historyErrorFeedbackSource) } finally { setDeletingId(null) } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index c7aa594..641718d 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -5,6 +5,7 @@ import ThemeProvider from '@/components/ThemeProvider' import LanguageProvider from '@/components/LanguageProvider' import VoiceSessionProvider from '@/components/VoiceSessionProvider' import ActiveSessionBar from '@/components/ActiveSessionBar' +import AppFeedbackPresenter from '@/components/AppFeedbackPresenter' export const metadata: Metadata = { title: 'MeteorVoice', @@ -27,6 +28,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + diff --git a/apps/web/app/settings/page.tsx b/apps/web/app/settings/page.tsx index 2008761..17077d7 100644 --- a/apps/web/app/settings/page.tsx +++ b/apps/web/app/settings/page.tsx @@ -2,12 +2,12 @@ import { useTheme, themes } from '@/components/ThemeProvider' import { useLocale, useT } from '@/components/LanguageProvider' -import { persistPreference, persistTTSSpeedPreference, readTTSSpeedPreference, ttsSpeedOptions, writeTTSSpeedPreference, type TTSSpeed } from '@/lib/tts-speed' +import { readTTSSpeedPreference, ttsSpeedOptions, writeTTSSpeedPreference, type TTSSpeed } from '@/lib/tts-speed' import { writeTTSVoiceIdPreference } from '@/lib/tts-voice' -import type { Locale, VoiceProfile } from '@meteorvoice/shared' +import { displayErrorFeedback, hideAppFeedback, showAppFeedback, type Locale, type VoiceProfile } from '@meteorvoice/shared' import { formatApiRequestError, readApiJsonResponse } from '@meteorvoice/api-client' import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' const allTtsProviders = [ { key: 'mock', labelKey: 'settings.tts_provider_mock' }, @@ -40,6 +40,18 @@ function getVoiceProfileName(profile: VoiceProfile, locale: Locale) { return locale === 'zh' ? profile.displayNameZh ?? profile.displayName : profile.displayName } +type PreferencesPayload = { + tts_provider?: string + available_providers?: string[] + tts_speed?: number + tts_voice_id?: string | null + voice_profiles?: VoiceProfile[] + selected_voice_profile_id?: string | null +} + +const settingsFeedbackSource = 'web_settings' +const settingsErrorFeedbackSource = 'web_settings_error' + export default function SettingsPage() { const { theme, setTheme } = useTheme() const { locale, setLocale } = useLocale() @@ -51,74 +63,126 @@ export default function SettingsPage() { const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState(null) const [availableProviders, setAvailableProviders] = useState(['mock']) const [settingsError, setSettingsError] = useState(null) + const [settingsLoading, setSettingsLoading] = useState(false) + const settingsSyncingMessage = t('settings.syncing') + + const applyPreferences = useCallback((data: PreferencesPayload) => { + setSettingsError(null) + if (data.tts_provider) setTtsProvider(data.tts_provider) + if (data.available_providers) setAvailableProviders(data.available_providers) + if (data.voice_profiles) setVoiceProfiles(data.voice_profiles) + if ('selected_voice_profile_id' in data) setSelectedVoiceProfileId(data.selected_voice_profile_id ?? null) + if ('tts_voice_id' in data) { + setTtsVoiceId(data.tts_voice_id ?? null) + writeTTSVoiceIdPreference(data.tts_voice_id ?? null) + } + if (typeof data.tts_speed === 'number') { + const serverSpeed = data.tts_speed + const nextSpeed = ttsSpeedOptions.reduce((best, option) => + Math.abs(option - serverSpeed) < Math.abs(best - serverSpeed) ? option : best, + ttsSpeedOptions[2]) + setTtsSpeed(nextSpeed) + writeTTSSpeedPreference(nextSpeed) + } + }, []) + + const loadPreferences = useCallback(async (showOverlay = false) => { + if (showOverlay) { + setSettingsLoading(true) + showAppFeedback({ + source: settingsFeedbackSource, + message: settingsSyncingMessage, + variant: 'hud', + blocksInteraction: true, + }) + } + try { + const res = await fetch('/api/preferences', { + headers: { 'X-MeteorVoice-Client': 'meteorvoice-web' }, + }) + const data = await readApiJsonResponse(res, 'Preferences request failed') + applyPreferences(data) + } catch (error) { + const requestError = formatApiRequestError(error, { + context: 'web_settings_preferences_load', + presentation: 'inline', + }) + setSettingsError(requestError.displayMessage) + } finally { + if (showOverlay) { + setSettingsLoading(false) + hideAppFeedback(settingsFeedbackSource) + } + } + }, [applyPreferences, settingsSyncingMessage]) useEffect(() => { - async function loadTtsProvider() { + async function loadInitialPreferences() { try { const res = await fetch('/api/preferences', { headers: { 'X-MeteorVoice-Client': 'meteorvoice-web' }, }) - 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) - if ('selected_voice_profile_id' in data) setSelectedVoiceProfileId(data.selected_voice_profile_id ?? null) - if ('tts_voice_id' in data) { - setTtsVoiceId(data.tts_voice_id ?? null) - writeTTSVoiceIdPreference(data.tts_voice_id ?? null) - } - if (typeof data.tts_speed === 'number') { - const serverSpeed = data.tts_speed - const nextSpeed = ttsSpeedOptions.reduce((best, option) => - Math.abs(option - serverSpeed) < Math.abs(best - serverSpeed) ? option : best, - ttsSpeedOptions[2]) - setTtsSpeed(nextSpeed) - writeTTSSpeedPreference(nextSpeed) - } + const data = await readApiJsonResponse(res, 'Preferences request failed') + applyPreferences(data) } catch (error) { - setSettingsError(formatApiRequestError(error, { + const requestError = formatApiRequestError(error, { context: 'web_settings_preferences_load', presentation: 'inline', - }).displayMessage) + }) + setSettingsError(requestError.displayMessage) } } - void loadTtsProvider() - }, []) + void loadInitialPreferences() + }, [applyPreferences]) + + async function savePreferences(body: Record, context: string) { + if (settingsLoading) return + setSettingsLoading(true) + setSettingsError(null) + showAppFeedback({ + source: settingsFeedbackSource, + message: settingsSyncingMessage, + variant: 'hud', + blocksInteraction: true, + }) + try { + const res = await fetch('/api/preferences', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-web' }, + body: JSON.stringify(body), + }) + await readApiJsonResponse(res, 'Preferences update failed') + await loadPreferences() + } catch (error) { + const requestError = formatApiRequestError(error, { + context, + presentation: 'banner', + }) + setSettingsError(requestError.displayMessage) + displayErrorFeedback(requestError, settingsErrorFeedbackSource) + } finally { + setSettingsLoading(false) + hideAppFeedback(settingsFeedbackSource) + } + } function handleTtsProviderChange(key: string) { - setTtsProvider(key) const nextProfile = voiceProfiles.find(profile => profile.provider === key && profile.status === 'active') - if (nextProfile) { - setSelectedVoiceProfileId(nextProfile.id) - setTtsVoiceId(nextProfile.providerVoiceId) - writeTTSVoiceIdPreference(nextProfile.providerVoiceId) - } - void persistPreference('tts_provider', key) - if (nextProfile) void persistPreference('selected_voice_profile_id', nextProfile.id) + void savePreferences({ + tts_provider: key, + ...(nextProfile ? { selected_voice_profile_id: nextProfile.id } : {}), + }, 'web_settings_tts_provider_save') } function handleTtsSpeedChange(index: number) { const next = ttsSpeedOptions[index] ?? 1 - setTtsSpeed(next) - void persistTTSSpeedPreference(next) + void savePreferences({ tts_speed: next }, 'web_settings_tts_speed_save') } function handleVoiceProfileChange(profile: VoiceProfile) { if (profile.status !== 'active') return - setSelectedVoiceProfileId(profile.id) - setTtsProvider(profile.provider) - setTtsVoiceId(profile.providerVoiceId) - writeTTSVoiceIdPreference(profile.providerVoiceId) - void persistPreference('selected_voice_profile_id', profile.id) + void savePreferences({ selected_voice_profile_id: profile.id }, 'web_settings_voice_profile_save') } const providerVoiceProfiles = voiceProfiles.filter(profile => profile.provider === ttsProvider) @@ -152,6 +216,7 @@ export default function SettingsPage() { key={th.key} type="button" onClick={() => setTheme(th.key)} + disabled={settingsLoading} className={`chip-action ${th.key === theme ? 'is-active' : ''}`} > {t(th.labelKey)} @@ -176,6 +241,7 @@ export default function SettingsPage() { key={l.key} type="button" onClick={() => setLocale(l.key)} + disabled={settingsLoading} className={`chip-action ${l.key === locale ? 'is-active' : ''}`} > {l.label} @@ -217,7 +283,7 @@ export default function SettingsPage() { key={provider.key} type="button" onClick={() => isAvailable && handleTtsProviderChange(provider.key)} - disabled={!isAvailable} + disabled={settingsLoading || !isAvailable} title={isAvailable ? undefined : t('settings.tts_not_configured')} className={`chip-action ${provider.key === ttsProvider ? 'is-active' : ''} ${!isAvailable ? 'opacity-40 cursor-not-allowed' : ''}`} > @@ -264,7 +330,7 @@ export default function SettingsPage() {