feat: add Xiaomi MiMo as a first-class TTS provider#129
feat: add Xiaomi MiMo as a first-class TTS provider#129daggerhashimoto merged 6 commits intomasterfrom
Conversation
📝 WalkthroughWalkthroughThis pull request adds Xiaomi MiMo TTS as a new text-to-speech provider. The implementation spans backend API key management, a new synthesis service that interfaces with Xiaomi's API, route handlers for configuration endpoints, and frontend UI components for provider selection and voice/style configuration. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Frontend as Frontend<br/>(AudioSettings)
participant TTS_API as TTS Route<br/>Handler
participant Xiaomi_Svc as Xiaomi Service<br/>(synthesizeXiaomi)
participant Xiaomi_API as Xiaomi API<br/>api.xiaomimimo.com
User->>Frontend: Select Xiaomi provider
Frontend->>Frontend: Update local state
alt No API Key Set
Frontend->>Frontend: Show API key prompt
User->>Frontend: Enter MIMO_API_KEY
Frontend->>TTS_API: PUT /api/keys {mimoApiKey}
TTS_API->>TTS_API: Persist to .env & config
end
User->>Frontend: Send text for synthesis
Frontend->>TTS_API: POST /api/tts {provider: 'xiaomi', text}
TTS_API->>Xiaomi_Svc: synthesizeXiaomi(text, opts)
Xiaomi_Svc->>Xiaomi_Svc: Load config.mimoApiKey<br/>Derive model/voice<br/>Wrap style as XML
Xiaomi_Svc->>Xiaomi_API: POST /v1/chat/completions<br/>with audio.format='wav'
alt Success Path
Xiaomi_API-->>Xiaomi_Svc: {choices[0].message.audio.data: base64}
Xiaomi_Svc->>Xiaomi_Svc: Decode base64 to Buffer
Xiaomi_Svc-->>TTS_API: {ok: true, buf, contentType}
else Error Path
Xiaomi_API-->>Xiaomi_Svc: Error response
Xiaomi_Svc-->>TTS_API: {ok: false, status, message}
end
TTS_API-->>Frontend: Audio buffer or error
Frontend-->>User: Play audio or display error
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes The changes span multiple architectural layers (backend service, route handlers, API integration, frontend UI and hooks) with heterogeneous logic including external API communication, error handling, config validation, and state management. Key files like Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/contexts/SettingsContext.tsx (1)
209-215:⚠️ Potential issue | 🟠 MajorReset
ttsModelwhen cycling providers.
toggleTtsProviderupdates only provider, while Line 140changeTtsProvideralso clears provider-specific model. This can leave stale models (e.g., OpenAI model while on Xiaomi) and cause synthesis failures.💡 Suggested fix
const toggleTtsProvider = useCallback(() => { - setTtsProvider(prev => { - const order: TTSProvider[] = ['openai', 'replicate', 'xiaomi', 'edge']; - const next = order[(order.indexOf(prev) + 1) % order.length]!; - localStorage.setItem('oc-tts-provider', next); - return next; - }); -}, []); + const order: TTSProvider[] = ['openai', 'replicate', 'xiaomi', 'edge']; + const next = order[(order.indexOf(ttsProvider) + 1) % order.length]!; + changeTtsProvider(next); +}, [ttsProvider, changeTtsProvider]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/contexts/SettingsContext.tsx` around lines 209 - 215, toggleTtsProvider currently only advances the provider and stores 'oc-tts-provider', but leaves the provider-specific model in state and localStorage causing stale-model synth failures; update toggleTtsProvider (the useCallback) to also clear ttsModel state and remove the stored model (same key used by changeTtsProvider, e.g., 'oc-tts-model')—mirror what changeTtsProvider does by calling the state setter that clears ttsModel and removing the model from localStorage whenever you set the new provider.
🧹 Nitpick comments (3)
server/routes/api-keys.test.ts (1)
41-67: Consider adding a test for the unconfigured key state.The tests verify the happy path (key configured), but don't verify that
xiaomiKeySet: falseis returned whenmimoKeyis empty. This would mirror the existing behavior for other providers.💡 Suggested additional test case
+ it('reports xiaomiKeySet as false when key is not configured', async () => { + mockDeps({ mimoKey: '' }); + const app = await buildApp(); + + const res = await app.request('/api/keys'); + expect(res.status).toBe(200); + + const json = await res.json() as Record<string, unknown>; + expect(json.xiaomiKeySet).toBe(false); + });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/routes/api-keys.test.ts` around lines 41 - 67, Add a test that verifies xiaomiKeySet is false when no MIMO key is configured: use mockDeps() with no mimoKey, call buildApp() and GET '/api/keys', assert status 200 and that the returned JSON has xiaomiKeySet === false; place it alongside the existing tests (near 'reports xiaomiKeySet from config' and 'writes MIMO_API_KEY from mimoApiKey input') and reuse mockDeps and buildApp to keep test setup consistent.server/services/xiaomi-tts.test.ts (1)
81-100: Consider adding a test for upstream HTTP errors.The service has error handling for when
resp.okis false (lines 61-65 in xiaomi-tts.ts), but this path isn't covered by the tests.💡 Suggested test case for HTTP errors
+ it('returns upstream error when Xiaomi API returns non-OK status', async () => { + vi.doMock('../lib/config.js', () => ({ + config: { mimoApiKey: 'sk-mimo' }, + })); + + vi.doMock('../lib/tts-config.js', () => ({ + getTTSConfig: () => ({ xiaomi: { model: 'mimo-v2-tts', voice: 'mimo_default', style: '' } }), + })); + + vi.mocked(fetch).mockResolvedValue( + new Response('Rate limit exceeded', { status: 429 }), + ); + + const { synthesizeXiaomi } = await import('./xiaomi-tts.js'); + await expect(synthesizeXiaomi('Hello')).resolves.toMatchObject({ + ok: false, + status: 429, + message: 'Rate limit exceeded', + }); + });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/services/xiaomi-tts.test.ts` around lines 81 - 100, Add a new unit test in xiaomi-tts.test.ts that covers the branch where the upstream HTTP response is not ok: mock fetch to return a Response with ok=false (e.g., status 500 and an error body), import synthesizeXiaomi from ./xiaomi-tts.js and assert the resolved result has ok: false and the expected status/message handling for non-ok responses (matching the code path that checks resp.ok in synthesizeXiaomi).server/services/xiaomi-tts.ts (1)
40-59: Add a timeout to the fetch request to prevent hanging on slow API responses.The
fetchcall (lines 40-59) has no timeout, which could cause it to hang indefinitely if the Xiaomi API becomes unresponsive. This risks tying up resources in high-concurrency scenarios.⏱️ Add AbortSignal timeout
const resp = await fetch(XIAOMI_TTS_URL, { + signal: AbortSignal.timeout(30_000), // 30 second timeout method: 'POST', headers: { Authorization: `Bearer ${config.mimoApiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: effectiveModel, messages: [ { role: 'assistant', content, }, ], audio: { format: 'wav', voice: effectiveVoice, }, }), });Note:
AbortErrorwill be thrown on timeout and must be handled—either catch it explicitly in this function or rely on the caller's error handler.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/services/xiaomi-tts.ts` around lines 40 - 59, The fetch to XIAOMI_TTS_URL that assigns to resp lacks a timeout; wrap the fetch with an AbortController (create controller, pass controller.signal to fetch) and set a setTimeout to call controller.abort() after a sensible timeout (e.g., configurable ms), then clearTimeout on success; ensure the fetch rejection for AbortError is either caught here or allowed to propagate so callers can handle it; update the code around the resp fetch invocation to use controller.signal and clear the timeout when the response is received.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@src/contexts/SettingsContext.tsx`:
- Around line 209-215: toggleTtsProvider currently only advances the provider
and stores 'oc-tts-provider', but leaves the provider-specific model in state
and localStorage causing stale-model synth failures; update toggleTtsProvider
(the useCallback) to also clear ttsModel state and remove the stored model (same
key used by changeTtsProvider, e.g., 'oc-tts-model')—mirror what
changeTtsProvider does by calling the state setter that clears ttsModel and
removing the model from localStorage whenever you set the new provider.
---
Nitpick comments:
In `@server/routes/api-keys.test.ts`:
- Around line 41-67: Add a test that verifies xiaomiKeySet is false when no MIMO
key is configured: use mockDeps() with no mimoKey, call buildApp() and GET
'/api/keys', assert status 200 and that the returned JSON has xiaomiKeySet ===
false; place it alongside the existing tests (near 'reports xiaomiKeySet from
config' and 'writes MIMO_API_KEY from mimoApiKey input') and reuse mockDeps and
buildApp to keep test setup consistent.
In `@server/services/xiaomi-tts.test.ts`:
- Around line 81-100: Add a new unit test in xiaomi-tts.test.ts that covers the
branch where the upstream HTTP response is not ok: mock fetch to return a
Response with ok=false (e.g., status 500 and an error body), import
synthesizeXiaomi from ./xiaomi-tts.js and assert the resolved result has ok:
false and the expected status/message handling for non-ok responses (matching
the code path that checks resp.ok in synthesizeXiaomi).
In `@server/services/xiaomi-tts.ts`:
- Around line 40-59: The fetch to XIAOMI_TTS_URL that assigns to resp lacks a
timeout; wrap the fetch with an AbortController (create controller, pass
controller.signal to fetch) and set a setTimeout to call controller.abort()
after a sensible timeout (e.g., configurable ms), then clearTimeout on success;
ensure the fetch rejection for AbortError is either caught here or allowed to
propagate so callers can handle it; update the code around the resp fetch
invocation to use controller.signal and clear the timeout when the response is
received.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2a80dfb5-d909-43f5-83b4-9031f0fe9af4
📒 Files selected for processing (16)
server/lib/config.tsserver/lib/tts-config.test.tsserver/lib/tts-config.tsserver/routes/api-keys.test.tsserver/routes/api-keys.tsserver/routes/tts.test.tsserver/routes/tts.tsserver/services/xiaomi-tts.test.tsserver/services/xiaomi-tts.tssrc/contexts/SettingsContext.tsxsrc/features/command-palette/commands.tssrc/features/settings/AudioSettings.component.test.tsxsrc/features/settings/AudioSettings.tsxsrc/features/tts/useTTS.test.tssrc/features/tts/useTTS.tssrc/features/tts/useTTSConfig.ts
Summary
Add Xiaomi MiMo as a manual TTS provider in Nerve.
What changed
MIMO_API_KEYand/api/keys/api/ttsrequests toprovider: 'xiaomi'Verification
npx vitest run server/routes/api-keys.test.ts server/services/xiaomi-tts.test.ts server/routes/tts.test.ts server/lib/tts-config.test.ts src/features/tts/useTTS.test.ts src/features/settings/AudioSettings.component.test.tsxnpm run lint -- --quietnpm run build