From f2ec38918cecc641926777530f31563ef7e072fa Mon Sep 17 00:00:00 2001 From: ConnorQi Date: Sat, 11 Jul 2026 22:55:37 +0800 Subject: [PATCH 1/2] Restore speech playback and mobile history refresh --- apps/mobile/app/(tabs)/history.tsx | 22 +++++--- .../ios/MeteorVoice.xcodeproj/project.pbxproj | 8 +-- apps/mobile/src/App.tsx | 4 +- apps/mobile/src/AppShell.tsx | 2 +- apps/mobile/src/components/ContentState.tsx | 47 +++++++++++++++++ apps/mobile/src/historyRefresh.ts | 5 ++ .../mobile/src/hooks/useHistoryScreenState.ts | 50 +++++++++++++++---- apps/mobile/src/screens/HistoryScreen.tsx | 45 ++++++++--------- apps/web/lib/hooks/use-tts-engine.ts | 2 +- apps/web/lib/providers/xunfei-tts.ts | 16 +++++- apps/web/lib/server/api-input.ts | 5 +- packages/shared/src/i18n.ts | 4 ++ packages/shared/src/speech.ts | 24 ++------- scripts/release-manager.mjs | 24 ++++++++- tests/api-input.test.ts | 28 +++++++++++ tests/mobile-history-refresh.test.ts | 29 +++++++++++ tests/mobile-version-sync.test.ts | 24 +++++++++ tests/tts-speed.test.ts | 6 +-- tests/xunfei-tts.test.ts | 11 +++- 19 files changed, 281 insertions(+), 75 deletions(-) create mode 100644 apps/mobile/src/components/ContentState.tsx create mode 100644 apps/mobile/src/historyRefresh.ts create mode 100644 tests/mobile-history-refresh.test.ts create mode 100644 tests/mobile-version-sync.test.ts diff --git a/apps/mobile/app/(tabs)/history.tsx b/apps/mobile/app/(tabs)/history.tsx index 9e8a7a1..c06ff79 100644 --- a/apps/mobile/app/(tabs)/history.tsx +++ b/apps/mobile/app/(tabs)/history.tsx @@ -3,22 +3,32 @@ * 历史标签页 — 会话历史与回顾界面。 */ -import { useMobileAuth } from '../../src/mobileAuth' +import { + useCallback, + useState, +} from 'react' +import { useFocusEffect } from 'expo-router' + import { HistoryScreen } from '../../src/screens/HistoryScreen' import { useSession } from '../../src/SessionContext' export default function HistoryTab() { - const { tr, locale, api } = useSession() - const auth = useMobileAuth() + const { tr, locale, api, auth, handleUnauthorized } = useSession() + const [focusVersion, setFocusVersion] = useState(0) + + useFocusEffect(useCallback(() => { + setFocusVersion(value => value + 1) + }, [])) return ( auth.signOut(null)} - defaultApiBaseUrl="" + authState={auth.state} + authUserId={auth.user?.id ?? null} + handleUnauthorized={handleUnauthorized} + refreshKey={focusVersion} /> ) } diff --git a/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj b/apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj index 94d714e..40f64c1 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 = 2026060601; + CURRENT_PROJECT_VERSION = 2026071101; DEVELOPMENT_TEAM = VY69DPYH3T; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -351,7 +351,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.4.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -376,7 +376,7 @@ CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2026060601; + CURRENT_PROJECT_VERSION = 2026071101; DEVELOPMENT_TEAM = VY69DPYH3T; INFOPLIST_FILE = MeteorVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; @@ -384,7 +384,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.4.0; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 838991a..6883b0a 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -471,8 +471,8 @@ function AppInner({ children }: { children?: React.ReactNode }) { // ─── Orchestration / 编排函数 ─── const synthesizeCoachSpeech = useCallback(async (text: string) => { - return api.synthesizeSpeech({ text, accent: accent.name, provider: ttsProvider, speed: ttsSpeedRouting.serverSpeed, voiceId: ttsVoiceId ?? undefined }) - }, [accent.name, api, ttsProvider, ttsSpeedRouting.serverSpeed, ttsVoiceId]) + return api.synthesizeSpeech({ text, accent: accent.name, provider: ttsProvider, speed: ttsSpeedRouting.requestSpeed, voiceId: ttsVoiceId ?? undefined }) + }, [accent.name, api, ttsProvider, ttsSpeedRouting.requestSpeed, ttsVoiceId]) const submitTurn = useCallback(async (sourceTranscript: string) => { const submitStartedAt = Date.now() diff --git a/apps/mobile/src/AppShell.tsx b/apps/mobile/src/AppShell.tsx index 7f32d46..04903cc 100644 --- a/apps/mobile/src/AppShell.tsx +++ b/apps/mobile/src/AppShell.tsx @@ -134,7 +134,7 @@ export function AppShell({ {activeTab === 'home' && setActiveTab('session')} />} {activeTab === 'session' && } - {activeTab === 'history' && } + {activeTab === 'history' && } {activeTab === 'settings' && } diff --git a/apps/mobile/src/components/ContentState.tsx b/apps/mobile/src/components/ContentState.tsx new file mode 100644 index 0000000..68f1537 --- /dev/null +++ b/apps/mobile/src/components/ContentState.tsx @@ -0,0 +1,47 @@ +import { useMemo } from 'react' +import { + ActivityIndicator, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native' + +import { useTheme } from '../ThemeProvider' + +interface Props { + actionLabel?: string + icon?: string + loading?: boolean + message?: string + onAction?: () => void + title: string +} + +export function ContentState({ actionLabel, icon = '○', loading = false, message, onAction, title }: Props) { + const { C } = useTheme() + const styles = useMemo(() => StyleSheet.create({ + action: { backgroundColor: C.surface, borderColor: C.border, borderRadius: 9, borderWidth: 1, marginTop: 6, paddingHorizontal: 14, paddingVertical: 9 }, + actionText: { color: C.accent, fontSize: 13, fontWeight: '700' }, + icon: { color: C.textMuted, fontSize: 28, fontWeight: '600' }, + iconSurface: { alignItems: 'center', backgroundColor: C.surface, borderColor: C.border, borderRadius: 28, borderWidth: 1, height: 56, justifyContent: 'center', marginBottom: 10, width: 56 }, + message: { color: C.textMuted, fontSize: 13, lineHeight: 19, maxWidth: 260, textAlign: 'center' }, + root: { alignItems: 'center', flex: 1, justifyContent: 'center', paddingHorizontal: 24, paddingVertical: 40 }, + title: { color: C.textSecondary, fontSize: 15, fontWeight: '700', marginBottom: 4, textAlign: 'center' }, + }), [C]) + + return ( + + + {loading ? : {icon}} + + {title} + {message ? {message} : null} + {actionLabel && onAction ? ( + + {actionLabel} + + ) : null} + + ) +} diff --git a/apps/mobile/src/historyRefresh.ts b/apps/mobile/src/historyRefresh.ts new file mode 100644 index 0000000..a7838f3 --- /dev/null +++ b/apps/mobile/src/historyRefresh.ts @@ -0,0 +1,5 @@ +export type HistoryAuthState = 'unconfigured' | 'loading' | 'signed-out' | 'signed-in' | 'error' + +export function getHistoryLoadRequestKey(authState: HistoryAuthState, userId: string | null, focusVersion: number) { + return authState === 'signed-in' && userId ? `${userId}:${focusVersion}` : null +} diff --git a/apps/mobile/src/hooks/useHistoryScreenState.ts b/apps/mobile/src/hooks/useHistoryScreenState.ts index b7c8439..4eb5ac2 100644 --- a/apps/mobile/src/hooks/useHistoryScreenState.ts +++ b/apps/mobile/src/hooks/useHistoryScreenState.ts @@ -16,6 +16,11 @@ import type { MeteorVoiceApiClient, SessionTurnDto, } from '@meteorvoice/api-client' +import { + getHistoryLoadRequestKey, + type HistoryAuthState, +} from '../historyRefresh' + import { formatApiRequestError, MeteorVoiceApiError, @@ -23,44 +28,61 @@ import { interface UseHistoryScreenStateInput { api: MeteorVoiceApiClient + authState: HistoryAuthState + authUserId: string | null handleUnauthorized: () => void + refreshKey: number } export function useHistoryScreenState({ api, + authState, + authUserId, handleUnauthorized, + refreshKey, }: UseHistoryScreenStateInput) { const [expandedId, setExpandedId] = useState(null) const [filterScenario, setFilterScenario] = useState(null) const [sessions, setSessions] = useState([]) + const [loadedUserId, setLoadedUserId] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [selectedHistory, setSelectedHistory] = useState(null) const [selectedTurns, setSelectedTurns] = useState([]) - const autoLoadRef = useRef(false) + const lastLoadKeyRef = useRef(null) + const loadingRef = useRef(false) const loadHistory = useCallback(async () => { - if (loading) return + if (loadingRef.current || authState !== 'signed-in' || !authUserId) return + loadingRef.current = true setLoading(true) setError(null) try { const result = await api.listHistory() setSessions(result.sessions) + setLoadedUserId(authUserId) setSelectedHistory(result.sessions[0] ?? null) setSelectedTurns([]) } catch (error) { + if (error instanceof MeteorVoiceApiError && error.status === 401) handleUnauthorized() const requestError = formatApiRequestError(error, { context: 'mobile_history_list', presentation: 'inline' }) setError(requestError.displayMessage) } finally { + loadingRef.current = false setLoading(false) } - }, [api, loading]) + }, [api, authState, authUserId, handleUnauthorized]) useEffect(() => { - if (autoLoadRef.current) return - autoLoadRef.current = true + const loadKey = getHistoryLoadRequestKey(authState, authUserId, refreshKey) + if (!loadKey) { + lastLoadKeyRef.current = null + return + } + if (lastLoadKeyRef.current === loadKey) return + lastLoadKeyRef.current = loadKey void loadHistory() - }, [loadHistory]) + }, [authState, authUserId, loadHistory, refreshKey]) const deleteSession = useCallback(async (id: string) => { setSessions(prev => prev.map(session => session.id === id ? { ...session, status: 'deleted' as const } : session)) @@ -85,22 +107,27 @@ export function useHistoryScreenState({ } }, [api]) + const visibleSessions = useMemo( + () => authUserId && loadedUserId === authUserId ? sessions : [], + [authUserId, loadedUserId, sessions], + ) + const toggle = useCallback((id: string | number) => { if (expandedId === id) { setExpandedId(null) return } - const nextSession = sessions.find(session => session.id === id) + const nextSession = visibleSessions.find(session => session.id === id) if (!nextSession) return setExpandedId(id) void selectHistory(nextSession) - }, [expandedId, selectHistory, sessions]) + }, [expandedId, selectHistory, visibleSessions]) const filtered = useMemo(() => ( filterScenario - ? sessions.filter(session => session.scenario_key === filterScenario || session.scenario === filterScenario) - : sessions - ), [filterScenario, sessions]) + ? visibleSessions.filter(session => session.scenario_key === filterScenario || session.scenario === filterScenario) + : visibleSessions + ), [filterScenario, visibleSessions]) return { deleteSession, @@ -108,6 +135,7 @@ export function useHistoryScreenState({ expandedId, filtered, filterScenario, + hasSessions: visibleSessions.length > 0, loadHistory, loading, selectedHistory, diff --git a/apps/mobile/src/screens/HistoryScreen.tsx b/apps/mobile/src/screens/HistoryScreen.tsx index af57606..b78a37f 100644 --- a/apps/mobile/src/screens/HistoryScreen.tsx +++ b/apps/mobile/src/screens/HistoryScreen.tsx @@ -31,15 +31,18 @@ import { } from '@meteorvoice/shared' import { useHistoryScreenState } from '../hooks/useHistoryScreenState' +import type { HistoryAuthState } from '../historyRefresh' +import { ContentState } from '../components/ContentState' import { useTheme } from '../ThemeProvider' interface Props { tr: TranslateFn locale: Locale api: MeteorVoiceApiClient - getAuthHeaders: () => Promise + authState: HistoryAuthState + authUserId: string | null handleUnauthorized: () => void - defaultApiBaseUrl: string + refreshKey: number } function scenarioLabel(entry: HistorySession, locale: Locale) { @@ -52,7 +55,7 @@ function accentLabel(entry: HistorySession, locale: Locale) { return a ? getAccentLabel(a, locale) : entry.accent } -export function HistoryScreen({ tr, locale, api, handleUnauthorized }: Props) { +export function HistoryScreen({ tr, locale, api, authState, authUserId, handleUnauthorized, refreshKey }: Props) { const { C } = useTheme() const { deleteSession, @@ -60,24 +63,20 @@ export function HistoryScreen({ tr, locale, api, handleUnauthorized }: Props) { expandedId, filtered, filterScenario, + hasSessions, loadHistory, loading, selectedHistory, selectedTurns, setFilterScenario, toggle, - } = useHistoryScreenState({ api, handleUnauthorized }) + } = useHistoryScreenState({ api, authState, authUserId, handleUnauthorized, refreshKey }) // ─── Styles / 样式 ─── const styles = useMemo(() => StyleSheet.create({ shell: { flex: 1, backgroundColor: C.bg }, - header: { - flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingHorizontal: 16, paddingVertical: 12, - }, + header: { paddingHorizontal: 16, paddingVertical: 12 }, title: { color: C.textPrimary, fontSize: 22, fontWeight: '800' }, - loadBtn: { backgroundColor: C.surface, borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8 }, - loadTxt: { color: C.accent, fontSize: 13, fontWeight: '700' }, filterBar: { maxHeight: 44 }, filterContent: { paddingHorizontal: 16, gap: 8, paddingBottom: 8 }, filterChip: { @@ -87,9 +86,6 @@ export function HistoryScreen({ tr, locale, api, handleUnauthorized }: Props) { filterChipActive: { backgroundColor: C.accent, borderColor: C.accent }, filterChipTxt: { color: C.textSecondary, fontSize: 12, fontWeight: '600' }, filterChipTxtActive: { color: C.cream }, - error: { color: '#ff8a80', fontSize: 13, paddingHorizontal: 16, marginBottom: 8 }, - empty: { flex: 1, alignItems: 'center', justifyContent: 'center' }, - emptyTxt: { color: C.textMuted, fontSize: 14 }, list: { paddingHorizontal: 16, gap: 10, paddingTop: 4 }, card: { backgroundColor: C.surface, borderRadius: 12, @@ -138,13 +134,10 @@ export function HistoryScreen({ tr, locale, api, handleUnauthorized }: Props) { {tr('history.title')} - - {loading ? tr('history.loading') : tr('nav.history') || 'Refresh'} - {/* 场景筛选 */} - + {authState === 'signed-in' && hasSessions ? setFilterScenario(null)} style={[styles.filterChip, filterScenario === null && styles.filterChipActive]}> {tr('history.filter_all')} @@ -153,17 +146,21 @@ export function HistoryScreen({ tr, locale, api, handleUnauthorized }: Props) { {s.icon} {getScenarioLabel(s, locale)} ))} - - - {error && {error}} + : null} - {filtered.length === 0 && !loading ? ( - - {tr('history.empty')} - + {authState !== 'signed-in' ? ( + + ) : loading && !hasSessions ? ( + + ) : error && !hasSessions ? ( + + ) : filtered.length === 0 ? ( + ) : ( String(item.id)} contentContainerStyle={styles.list} renderItem={({ item }) => { diff --git a/apps/web/lib/hooks/use-tts-engine.ts b/apps/web/lib/hooks/use-tts-engine.ts index a411919..98b6c92 100644 --- a/apps/web/lib/hooks/use-tts-engine.ts +++ b/apps/web/lib/hooks/use-tts-engine.ts @@ -86,7 +86,7 @@ export function useTTSEngine(ctx: TTSEngineContext): TTSEngine { const res = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-MeteorVoice-Client': 'meteorvoice-web' }, - body: JSON.stringify({ text: speechText, accent: accentName, provider, speed: speedRouting.serverSpeed, voiceId: ttsVoiceIdRef.current }), + body: JSON.stringify({ text: speechText, accent: accentName, provider, speed: speedRouting.requestSpeed, voiceId: ttsVoiceIdRef.current }), }) const result = await readApiJsonResponse<{ audioUrl?: string }>(res, 'TTS request failed') if (!result.audioUrl) throw new Error('TTS response did not include audioUrl') diff --git a/apps/web/lib/providers/xunfei-tts.ts b/apps/web/lib/providers/xunfei-tts.ts index ece1234..8519a7e 100644 --- a/apps/web/lib/providers/xunfei-tts.ts +++ b/apps/web/lib/providers/xunfei-tts.ts @@ -14,6 +14,9 @@ import { resolveXunfeiVoiceForText } from './xunfei-voices' const host = 'tts-api.xfyun.cn' const path = '/v2/tts' +const xunfeiMinSpeed = 50 +const xunfeiNormalSpeed = 55 +const xunfeiMaxSpeed = 80 type XunfeiTTSEnvironment = Record @@ -27,6 +30,17 @@ function readCredential(value?: string) { return value?.trim() || null } +export function mapXunfeiSpeedMultiplier(speed = 1) { + const normalized = Math.min(1.5, Math.max(0.75, Number.isFinite(speed) ? speed : 1)) + if (normalized <= 1) { + const progress = (normalized - 0.75) / 0.25 + return Math.round(xunfeiMinSpeed + ((xunfeiNormalSpeed - xunfeiMinSpeed) * progress)) + } + + const progress = (normalized - 1) / 0.5 + return Math.round(xunfeiNormalSpeed + ((xunfeiMaxSpeed - xunfeiNormalSpeed) * progress)) +} + export function hasXunfeiTTSCredentials(env: XunfeiTTSEnvironment = process.env) { const appId = readCredential(env.XUNFEI_APP_ID) const apiPassword = readCredential(env.XUNFEI_API_PASSWORD) @@ -97,7 +111,7 @@ export function createXunfeiTTS(): TTSProvider { aue: 'lame', sfl: 1, vcn: resolveXunfeiVoiceForText(text, options?.accent, process.env, Date.now(), options?.voiceId), - speed: Math.round(Math.min(100, Math.max(0, options?.speed ?? 70))), + speed: mapXunfeiSpeedMultiplier(options?.speed), volume: 50, pitch: 50, bgs: 0, diff --git a/apps/web/lib/server/api-input.ts b/apps/web/lib/server/api-input.ts index 94f89f7..24e9ec0 100644 --- a/apps/web/lib/server/api-input.ts +++ b/apps/web/lib/server/api-input.ts @@ -83,10 +83,13 @@ export function parseChatRequest(input: unknown): ApiInputResult<{ export function parseTTSRequest(input: unknown): ApiInputResult { if (!isRecord(input) || typeof input.text !== 'string' || !input.text.trim()) return invalid('Text is required') if (input.text.length > 4000) return invalid('Text is too long') + const speedIsValid = input.speed === undefined || ( + isFiniteNumber(input.speed) && input.speed >= 0.5 && input.speed <= 2 + ) if (!isOptionalBoundedString(input.accent, 100) || !isOptionalBoundedString(input.provider, 50) || !isOptionalBoundedString(input.voiceId, 200) || - (input.speed !== undefined && (!isFiniteNumber(input.speed) || input.speed < 0.5 || input.speed > 2))) { + !speedIsValid) { return invalid('Invalid TTS options') } return { diff --git a/packages/shared/src/i18n.ts b/packages/shared/src/i18n.ts index 01af24d..57d7312 100644 --- a/packages/shared/src/i18n.ts +++ b/packages/shared/src/i18n.ts @@ -14,6 +14,7 @@ export const t: Record> = { en: { 'common.cancel': 'Cancel', 'common.confirm': 'Confirm', + 'common.retry': 'Try again', 'nav.home': 'Home', 'nav.practice': 'Practice', 'nav.history': 'History', @@ -166,6 +167,7 @@ export const t: Record> = { 'history.deleted': 'Session deleted.', 'history.load_error': 'Failed to load sessions.', 'history.auth_required': 'Please sign in to view history.', + 'history.auth_hint': 'Your sessions will load automatically after you sign in.', 'settings.title': 'Settings', 'settings.subtitle': 'Customize your practice environment.', 'settings.auth_expired': 'Your login has expired. Please sign in again.', @@ -281,6 +283,7 @@ export const t: Record> = { zh: { 'common.cancel': '取消', 'common.confirm': '确认', + 'common.retry': '重试', 'nav.home': '首页', 'nav.practice': '练习', 'nav.history': '历史', @@ -433,6 +436,7 @@ export const t: Record> = { 'history.deleted': '记录已删除。', 'history.load_error': '加载失败。', 'history.auth_required': '请先登录后再查看历史记录。', + 'history.auth_hint': '登录成功后,练习记录会自动加载。', 'settings.title': '设置', 'settings.subtitle': '自定义你的练习环境。', 'settings.auth_expired': '登录已过期,请重新登录。', diff --git a/packages/shared/src/speech.ts b/packages/shared/src/speech.ts index f85a790..b2f69c4 100644 --- a/packages/shared/src/speech.ts +++ b/packages/shared/src/speech.ts @@ -155,26 +155,12 @@ export const ttsProviderCapabilities = { export type TTSProviderKey = keyof typeof ttsProviderCapabilities const calibratedNormalSpeechRate = 1.2 -const xunfeiMinSpeed = 50 -const xunfeiNormalSpeed = 55 -const xunfeiMaxSpeed = 80 function normalizeSpeedMultiplier(speed: number) { if (!Number.isFinite(speed)) return 1 return Math.min(1.5, Math.max(0.75, speed)) } -function mapXunfeiSpeed(speed: number) { - const normalized = normalizeSpeedMultiplier(speed) - if (normalized <= 1) { - const progress = (normalized - 0.75) / 0.25 - return Math.round(xunfeiMinSpeed + ((xunfeiNormalSpeed - xunfeiMinSpeed) * progress)) - } - - const progress = (normalized - 1) / 0.5 - return Math.round(xunfeiNormalSpeed + ((xunfeiMaxSpeed - xunfeiNormalSpeed) * progress)) -} - /** * Checks whether a TTS provider supports a given accent. * 检查某个 TTS 提供商是否支持指定的口音。 @@ -186,15 +172,15 @@ export function supportsAccent(provider: string, accent: string): boolean { } /** - * Computes server speed and client playback rate for a TTS provider based on the desired speed multiplier. - * 根据所需语速倍率,计算 TTS 提供商的服务端语速和客户端播放速率。 + * Computes the normalized API speed and client playback rate based on the desired speed multiplier. + * 根据所需语速倍率,计算统一 API 语速和客户端播放速率。 */ -export function getTTSSpeedRouting(provider: string, speed = 1): { serverSpeed: number; playbackRate: number } { +export function getTTSSpeedRouting(provider: string, speed = 1): { requestSpeed: number; playbackRate: number } { const capabilities = ttsProviderCapabilities[provider as TTSProviderKey] const normalizedSpeed = normalizeSpeedMultiplier(speed) if (capabilities?.speedControl === 'provider') { - return { serverSpeed: provider === 'xunfei' ? mapXunfeiSpeed(normalizedSpeed) : normalizedSpeed, playbackRate: 1 } + return { requestSpeed: normalizedSpeed, playbackRate: 1 } } - return { serverSpeed: 1, playbackRate: normalizedSpeed * calibratedNormalSpeechRate } + return { requestSpeed: 1, playbackRate: normalizedSpeed * calibratedNormalSpeechRate } } diff --git a/scripts/release-manager.mjs b/scripts/release-manager.mjs index a8983e1..e03b440 100644 --- a/scripts/release-manager.mjs +++ b/scripts/release-manager.mjs @@ -13,6 +13,7 @@ const config = { issuePrefix: '[Feature]', issueLabel: 'enhancement', mobileAppConfig: 'apps/mobile/app.json', + mobileXcodeProject: 'apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj', versionFiles: [ 'package.json', 'apps/web/package.json', @@ -82,7 +83,7 @@ async function prepareRelease() { updateVersionFiles(version) writeReleaseDoc(version) - run('git', ['add', ...config.versionFiles.filter(exists), config.releaseDoc(version)]) + run('git', ['add', ...config.versionFiles.filter(exists), config.mobileXcodeProject, config.releaseDoc(version)]) run('git', ['commit', '-m', `Prepare ${tag} release`]) run('git', ['push', '-u', 'origin', branch]) @@ -170,6 +171,7 @@ function mainAlreadyPrepared(targetVersion) { .filter((file) => file.endsWith('package.json')) .every((file) => readJson(file).version === targetVersion) && readJson(config.mobileAppConfig).expo?.version === targetVersion && + xcodeProjectAlreadyPrepared(targetVersion, readJson(config.mobileAppConfig).expo?.ios?.buildNumber) && existsSync(config.releaseDoc(targetVersion)) } @@ -193,6 +195,26 @@ function updateVersionFiles(targetVersion) { } writeJson(file, json) } + updateXcodeProjectVersion(targetVersion, mobileBuildNumber) +} + +function xcodeProjectAlreadyPrepared(targetVersion, buildNumber) { + if (!existsSync(config.mobileXcodeProject) || !buildNumber) return false + const project = readFileSync(config.mobileXcodeProject, 'utf8') + const marketingVersions = [...project.matchAll(/MARKETING_VERSION = ([^;]+);/g)].map(match => match[1]) + const buildNumbers = [...project.matchAll(/CURRENT_PROJECT_VERSION = ([^;]+);/g)].map(match => match[1]) + return marketingVersions.length > 0 && + buildNumbers.length > 0 && + marketingVersions.every(value => value === targetVersion) && + buildNumbers.every(value => value === String(buildNumber)) +} + +function updateXcodeProjectVersion(targetVersion, buildNumber) { + if (!existsSync(config.mobileXcodeProject)) return + const project = readFileSync(config.mobileXcodeProject, 'utf8') + .replace(/MARKETING_VERSION = [^;]+;/g, `MARKETING_VERSION = ${targetVersion};`) + .replace(/CURRENT_PROJECT_VERSION = [^;]+;/g, `CURRENT_PROJECT_VERSION = ${buildNumber};`) + writeFileSync(config.mobileXcodeProject, project) } function nextMobileBuildNumber(appConfig, now = new Date()) { diff --git a/tests/api-input.test.ts b/tests/api-input.test.ts index e3d63d8..3202669 100644 --- a/tests/api-input.test.ts +++ b/tests/api-input.test.ts @@ -59,6 +59,34 @@ describe('API input validation', () => { }) }) + it('accepts only normalized TTS speed multipliers for every provider', () => { + expect(parseTTSRequest({ + text: 'Hello', + provider: 'xunfei', + speed: 1.5, + })).toEqual({ + value: { + text: 'Hello', + accent: undefined, + provider: 'xunfei', + speed: 1.5, + voiceId: undefined, + }, + }) + + expect(parseTTSRequest({ + text: 'Hello', + provider: 'xunfei', + speed: 55, + })).toEqual({ error: 'Invalid TTS options', status: 400 }) + + expect(parseTTSRequest({ + text: 'Hello', + provider: 'mock', + speed: 55, + })).toEqual({ error: 'Invalid TTS options', status: 400 }) + }) + it('validates summary, turn, and session-sync payloads', () => { expect(parseSummaryRequest({ sessionId: 'session-1', diff --git a/tests/mobile-history-refresh.test.ts b/tests/mobile-history-refresh.test.ts new file mode 100644 index 0000000..d58fefc --- /dev/null +++ b/tests/mobile-history-refresh.test.ts @@ -0,0 +1,29 @@ +import { + describe, + expect, + it, +} from 'vitest' + +import { getHistoryLoadRequestKey } from '../apps/mobile/src/historyRefresh' + +describe('mobile history refresh lifecycle', () => { + it('does not load history before authentication succeeds', () => { + expect(getHistoryLoadRequestKey('loading', null, 1)).toBeNull() + expect(getHistoryLoadRequestKey('signed-out', null, 1)).toBeNull() + expect(getHistoryLoadRequestKey('signed-in', null, 1)).toBeNull() + }) + + it('loads after sign-in and reloads whenever the tab receives focus again', () => { + const firstFocus = getHistoryLoadRequestKey('signed-in', 'user-1', 1) + const secondFocus = getHistoryLoadRequestKey('signed-in', 'user-1', 2) + + expect(firstFocus).toBe('user-1:1') + expect(secondFocus).toBe('user-1:2') + expect(secondFocus).not.toBe(firstFocus) + }) + + it('uses the user id to prevent history from leaking across accounts', () => { + expect(getHistoryLoadRequestKey('signed-in', 'user-1', 1)) + .not.toBe(getHistoryLoadRequestKey('signed-in', 'user-2', 1)) + }) +}) diff --git a/tests/mobile-version-sync.test.ts b/tests/mobile-version-sync.test.ts new file mode 100644 index 0000000..b7bfa75 --- /dev/null +++ b/tests/mobile-version-sync.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs' + +import { + describe, + expect, + it, +} from 'vitest' + +import appConfig from '../apps/mobile/app.json' + +const xcodeProjectPath = new URL('../apps/mobile/ios/MeteorVoice.xcodeproj/project.pbxproj', import.meta.url) + +describe('mobile native version metadata', () => { + it('keeps every Xcode configuration aligned with the Expo app version and build number', () => { + const project = readFileSync(xcodeProjectPath, 'utf8') + const marketingVersions = [...project.matchAll(/MARKETING_VERSION = ([^;]+);/g)].map(match => match[1]) + const buildNumbers = [...project.matchAll(/CURRENT_PROJECT_VERSION = ([^;]+);/g)].map(match => match[1]) + + expect(marketingVersions.length).toBeGreaterThan(0) + expect(buildNumbers.length).toBeGreaterThan(0) + expect(new Set(marketingVersions)).toEqual(new Set([appConfig.expo.version])) + expect(new Set(buildNumbers)).toEqual(new Set([appConfig.expo.ios.buildNumber])) + }) +}) diff --git a/tests/tts-speed.test.ts b/tests/tts-speed.test.ts index 686e311..db69b16 100644 --- a/tests/tts-speed.test.ts +++ b/tests/tts-speed.test.ts @@ -29,8 +29,8 @@ describe('tts speed preferences', () => { }) it('maps Xunfei speeds conservatively to avoid clipped starts', () => { - expect(getTTSSpeedRouting('xunfei', 0.75)).toEqual({ serverSpeed: 50, playbackRate: 1 }) - expect(getTTSSpeedRouting('xunfei', 1)).toEqual({ serverSpeed: 55, playbackRate: 1 }) - expect(getTTSSpeedRouting('xunfei', 1.5)).toEqual({ serverSpeed: 80, playbackRate: 1 }) + expect(getTTSSpeedRouting('xunfei', 0.75)).toEqual({ requestSpeed: 0.75, playbackRate: 1 }) + expect(getTTSSpeedRouting('xunfei', 1)).toEqual({ requestSpeed: 1, playbackRate: 1 }) + expect(getTTSSpeedRouting('xunfei', 1.5)).toEqual({ requestSpeed: 1.5, playbackRate: 1 }) }) }) diff --git a/tests/xunfei-tts.test.ts b/tests/xunfei-tts.test.ts index 4468ec1..71c68cb 100644 --- a/tests/xunfei-tts.test.ts +++ b/tests/xunfei-tts.test.ts @@ -14,12 +14,21 @@ import { XUNFEI_TRIAL_VOICE_RYAN, XUNFEI_TRIAL_VOICE_YEZI, } from '@/lib/providers/xunfei-voices' -import { resolveXunfeiTTSConnection } from '@/lib/providers/xunfei-tts' +import { + mapXunfeiSpeedMultiplier, + resolveXunfeiTTSConnection, +} from '@/lib/providers/xunfei-tts' const beforeTrialExpiry = Date.parse(XUNFEI_TRIAL_VOICE_EXPIRES_AT) - 1 const afterTrialExpiry = Date.parse(XUNFEI_TRIAL_VOICE_EXPIRES_AT) describe('Xunfei TTS voice config', () => { + it('maps normalized product speed multipliers to Xunfei native values', () => { + expect(mapXunfeiSpeedMultiplier(0.75)).toBe(50) + expect(mapXunfeiSpeedMultiplier(1)).toBe(55) + expect(mapXunfeiSpeedMultiplier(1.5)).toBe(80) + }) + it('uses the active catalog default when no fallback env is configured', () => { expect(resolveXunfeiVoiceForAccent('American', {}, beforeTrialExpiry, XUNFEI_TRIAL_VOICE_RYAN)).toBe(XUNFEI_TRIAL_VOICE_RYAN) expect(hasXunfeiVoiceConfig({}, beforeTrialExpiry)).toBe(true) From 90be43945a7c8be2610c9778ee7a291e7bd5e803 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:00:19 +0800 Subject: [PATCH 2/2] Prepare v1.4.1 release --- apps/mobile/app.json | 6 ++-- .../ios/MeteorVoice.xcodeproj/project.pbxproj | 8 ++--- apps/mobile/package.json | 2 +- apps/web/package.json | 2 +- docs/releases/v1.4.1.md | 30 +++++++++++++++++++ package-lock.json | 8 ++--- package.json | 2 +- 7 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 docs/releases/v1.4.1.md diff --git a/apps/mobile/app.json b/apps/mobile/app.json index eb1b6a8..0926705 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -3,7 +3,7 @@ "name": "MeteorVoice", "slug": "meteorvoice-mobile", "owner": "junchenmeteors-team", - "version": "1.4.0", + "version": "1.4.1", "orientation": "portrait", "scheme": "meteorvoice", "userInterfaceStyle": "automatic", @@ -11,7 +11,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "com.jcmeteor.meteorvoice", - "buildNumber": "2026071101", + "buildNumber": "2026071102", "deploymentTarget": "18.0", "infoPlist": { "NSMicrophoneUsageDescription": "MeteorVoice uses the microphone for voice practice sessions.", @@ -26,7 +26,7 @@ }, "android": { "package": "com.jcmeteor.meteorvoice", - "versionCode": 2026071101, + "versionCode": 2026071102, "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 40f64c1..2758e6a 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 = 2026071101; + CURRENT_PROJECT_VERSION = 2026071102; DEVELOPMENT_TEAM = VY69DPYH3T; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -351,7 +351,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.4.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -376,7 +376,7 @@ CODE_SIGN_ENTITLEMENTS = MeteorVoice/MeteorVoice.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2026071101; + CURRENT_PROJECT_VERSION = 2026071102; DEVELOPMENT_TEAM = VY69DPYH3T; INFOPLIST_FILE = MeteorVoice/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 18.0; @@ -384,7 +384,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.0; + MARKETING_VERSION = 1.4.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9dd0131..4f5b568 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@meteorvoice/mobile", - "version": "1.4.0", + "version": "1.4.1", "private": true, "main": "index.ts", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index 366f565..5125878 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@meteorvoice/web", - "version": "1.4.0", + "version": "1.4.1", "private": true, "scripts": { "dev": "next dev", diff --git a/docs/releases/v1.4.1.md b/docs/releases/v1.4.1.md new file mode 100644 index 0000000..0c700c5 --- /dev/null +++ b/docs/releases/v1.4.1.md @@ -0,0 +1,30 @@ +# Release Notes + +Release focus: production promotion for MeteorVoice 1.4.1. + +## Highlights + +- Promoted validated main branch changes to the production release branch. +- Updated Web and mobile package versions to `1.4.1`. +- Published GitHub Release tag `v1.4.1`. + +## Deployment + +- Production branch: `release` +- Preview branch: `main` +- Production URL: `https://meteorvoice.jcmeteor.com/` +- Preview URL: `https://mv-pre.jcmeteor.com/` + +## Versioning + +- Web version: `1.4.1` +- Mobile version: `1.4.1` +- iOS build number: `2026071102` +- Android version code: `2026071102` +- Release tag: `v1.4.1` + +## Validation + +```bash +npm test +``` diff --git a/package-lock.json b/package-lock.json index 495a344..bf469f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meteorvoice", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meteorvoice", - "version": "1.4.0", + "version": "1.4.1", "workspaces": [ "packages/*", "apps/*" @@ -57,7 +57,7 @@ }, "apps/mobile": { "name": "@meteorvoice/mobile", - "version": "1.4.0", + "version": "1.4.1", "dependencies": { "@meteorvoice/api-client": "file:../../packages/api-client", "@meteorvoice/session-core": "file:../../packages/session-core", @@ -869,7 +869,7 @@ }, "apps/web": { "name": "@meteorvoice/web", - "version": "1.4.0" + "version": "1.4.1" }, "node_modules/@ai-sdk/deepseek": { "version": "2.0.35", diff --git a/package.json b/package.json index c7f11f4..6968de8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meteorvoice", - "version": "1.4.0", + "version": "1.4.1", "private": true, "workspaces": [ "packages/*",