diff --git a/docs/superpowers/plans/2026-07-01-tts-voice-pack-model-option-plan.md b/docs/superpowers/plans/2026-07-01-tts-voice-pack-model-option-plan.md new file mode 100644 index 000000000..7ce7b24ef --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-tts-voice-pack-model-option-plan.md @@ -0,0 +1,322 @@ +# TTS Voice Pack Model Option Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a local `Voice Pack` model option under the official TTS provider; when selected, the voice picker shows server-enabled Voice Packs instead of raw provider voices. + +**Architecture:** Keep provider selection unchanged. Treat `Voice Pack` as a local UI-only model id in the Speech settings page, and resolve it to the selected pack snapshot only when binding and building speech requests. Existing real TTS model + raw voice behavior stays unchanged. + +**Tech Stack:** Vue 3 Composition API, Pinia stores, Stage UI components, TypeScript, Vitest, UnoCSS. + +## Global Constraints + +- Do not create implementation commits. +- Do not add aliases to raw TTS voices. +- Do not filter raw provider catalogs through a new allowlist. +- Do not make Voice Pack the only official TTS path. +- Do not hardcode default male or female voices in the client. +- Do not change Voice Pack billing semantics. +- Do not change server-side `LLM_ROUTER_CONFIG` model aliases. +- Keep Voice Pack provider/model/voice ids hidden from the user-facing UI. + +--- + +## File Structure + +- Modify `packages/stage-pages/src/pages/settings/modules/speech.vue` + - Owns the user-facing Speech settings flow. + - Adds a local synthetic `Voice Pack` model option. + - Projects enabled Voice Packs into voice-card options when the synthetic option is selected. + - Removes the old separate Voice Pack section from the left column. +- Modify `packages/stage-ui/src/stores/modules/speech.ts` + - Exports the synthetic `VOICE_PACK_MODEL_ID`. + - Prevents the official raw-voice loader from requesting `/audio/voices?model=voice-pack`. + - Keeps the synthetic model selected when the official provider validates model state. +- Modify `packages/stage-ui/src/stores/modules/speech.test.ts` + - Adds regression coverage for existing Voice Pack request resolution. + - Confirms pack metadata stays attached to speech requests. + +### Task 1: Integrate Voice Pack Into Model And Voice Selection + +**Files:** +- Modify: `packages/stage-pages/src/pages/settings/modules/speech.vue` +- Modify: `packages/stage-ui/src/stores/modules/speech.ts` + +**Interfaces:** +- Consumes: `useVoicePacksStore().packs`, `useAiriCardStore().bindVoicePackToActiveCard(pack)`, `activeSpeechModel`, `activeSpeechVoiceId`, `availableVoices`. +- Produces: exported `VOICE_PACK_MODEL_ID`, local `displayedProviderModels`, `isVoicePackModelSelected`, and `displayedVoiceOptions` values used by the template. + +- [ ] **Step 1: Add the synthetic model id and display helpers** + +Add to `packages/stage-ui/src/stores/modules/speech.ts` near the Voice Pack parameter constants: + +```ts +export const VOICE_PACK_MODEL_ID = 'voice-pack' +``` + +Import it in `packages/stage-pages/src/pages/settings/modules/speech.vue`. + +Add near the other page-level constants: + +```ts +const VOICE_PACK_MODEL_OPTION = { + id: VOICE_PACK_MODEL_ID, + name: 'Voice Pack', + description: 'Server-curated voices', +} +``` + +Add computed helpers: + +```ts +const isOfficialSpeechProvider = computed(() => activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID) +const isVoicePackModelSelected = computed(() => isOfficialSpeechProvider.value && activeSpeechModel.value === VOICE_PACK_MODEL_ID) +const displayedProviderModels = computed(() => { + if (!isOfficialSpeechProvider.value) + return providerModels.value + return [VOICE_PACK_MODEL_OPTION, ...providerModels.value] +}) +``` + +- [ ] **Step 2: Replace the old separate Voice Pack section** + +Delete the `` block. Keep `voicePacksStore.load()` on mount. + +- [ ] **Step 3: Project Voice Packs into voice-card options** + +Add helpers: + +```ts +function voicePackVoiceId(packId: string) { + return `voice-pack:${packId}` +} + +function packIdFromVoicePackVoiceId(voiceId: string) { + return voiceId.startsWith('voice-pack:') ? voiceId.slice('voice-pack:'.length) : null +} + +const selectedVoicePack = computed(() => { + const packId = packIdFromVoicePackVoiceId(activeSpeechVoiceId.value) + if (packId) + return voicePacks.value.find(pack => pack.id === packId) ?? null + const snapshot = activeCard.value?.extensions.airi.modules.speech.voicePack + return snapshot ? voicePacks.value.find(pack => pack.id === snapshot.packId) ?? null : null +}) + +const displayedVoiceOptions = computed(() => { + if (isVoicePackModelSelected.value) { + return voicePacks.value.map(pack => ({ + id: voicePackVoiceId(pack.id), + name: pack.name, + description: pack.description ?? undefined, + previewURL: '', + customizable: false, + })) + } + + return (availableVoices.value[activeSpeechProvider.value] ?? []) + .filter((voice) => { + if (!activeSpeechModel.value) + return true + return !voice.compatibleModels || voice.compatibleModels.includes(activeSpeechModel.value) + }) + .map(voice => ({ + id: voice.id, + name: voice.name, + description: voice.description, + previewURL: voice.previewURL, + customizable: false, + })) +}) +``` + +- [ ] **Step 4: Handle Voice Pack voice selection** + +Update `selectSpeechVoice` so selecting a projected Voice Pack binds the server pack and tracks the underlying pack: + +```ts +async function selectSpeechVoice(voiceId: string | undefined) { + if (!voiceId) + return + + const packId = packIdFromVoicePackVoiceId(voiceId) + if (isVoicePackModelSelected.value && packId) { + const pack = voicePacks.value.find(item => item.id === packId) + if (!pack) + return + await bindVoicePack(pack) + activeSpeechVoiceId.value = voiceId + activeSpeechVoice.value = { + id: voiceId, + name: pack.name, + description: pack.description ?? pack.name, + previewURL: '', + languages: [{ code: 'en', title: 'English' }], + provider: activeSpeechProvider.value, + gender: 'neutral', + } + return + } + + trackVoiceSelected({ + tts_provider_id: activeSpeechProvider.value || 'unknown', + tts_model_id: currentTtsModelId(), + ...voiceAnalyticsPayload(voiceId), + source: 'settings', + }) +} +``` + +- [ ] **Step 5: Keep model switching from sending `voice-pack` to `/audio/voices`** + +Update the active model watcher: + +```ts +watch(activeSpeechModel, async (model) => { + if (!activeSpeechProvider.value) + return + + activeSpeechVoiceId.value = '' + activeSpeechVoice.value = undefined + + if (model === VOICE_PACK_MODEL_ID) + return + + await speechStore.loadVoicesForProvider(activeSpeechProvider.value, model || undefined) +}) +``` + +- [ ] **Step 6: Use displayed model and voice lists in the template** + +Change `providerModels` usages in the model picker to `displayedProviderModels`. + +Change the voice picker condition to: + +```vue +v-else-if="activeSpeechProvider !== 'openai-compatible-audio-speech' && displayedVoiceOptions.length > 0" +``` + +Change the voice picker `:voices` binding to: + +```vue +:voices="displayedVoiceOptions" +``` + +Change the manual voice input condition so it does not show for the Voice Pack model: + +```vue +v-if="!isVoicePackModelSelected && (activeSpeechProvider === 'openai-compatible-audio-speech' || !availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0)" +``` + +- [ ] **Step 7: Verify the page compiles** + +Run: + +```bash +pnpm -F @proj-airi/stage-pages typecheck +``` + +Expected: PASS, or unrelated pre-existing package errors documented with exact output. + +### Task 2: Preserve Voice Pack Speech Request Semantics + +**Files:** +- Modify: `packages/stage-pages/src/pages/settings/modules/speech.vue` +- Modify: `packages/stage-ui/src/stores/modules/speech.test.ts` + +**Interfaces:** +- Consumes: `bindVoicePackToActiveCard`, `voicePackForSpeechProvider`, `resolveVoicePackSpeechInput`. +- Produces: regression proof that the UI display change does not break server validation metadata. + +- [ ] **Step 1: Keep preview generation resolving snapshots** + +Ensure `generateTestSpeech()` keeps this behavior: + +```ts +const voicePack = boundVoicePack.value +if (voicePack) { + model = voicePack.ttsModelId + if (!voice || voice.id !== voicePack.voiceId) + voice = createVoicePackVoice(voicePack) +} +``` + +- [ ] **Step 2: Keep request metadata test coverage** + +Confirm or add a test in `packages/stage-ui/src/stores/modules/speech.test.ts`: + +```ts +it('passes Voice Pack snapshot billing metadata through adapter options', () => { + const speechStore = useSpeechStore() + const result = speechStore.resolveVoicePackSpeechInput({ + text: 'hello', + voice: { + id: 'voice-a', + name: 'Voice A', + provider: OFFICIAL_SPEECH_PROVIDER_ID, + languages: [{ code: 'en-US', title: 'English' }], + }, + voicePack: { + packId: 'vp-1', + costMultiplier: 1.5, + }, + }) + + expect(result.providerConfig.extraBody).toEqual({ + voice_pack: { + pack_id: 'vp-1', + cost_multiplier: 1.5, + }, + }) +}) +``` + +- [ ] **Step 3: Run targeted tests** + +Run: + +```bash +pnpm exec vitest run packages/stage-ui/src/stores/modules/speech.test.ts +``` + +Expected: PASS. + +### Task 3: Final Verification + +**Files:** +- Check: `packages/stage-pages/src/pages/settings/modules/speech.vue` +- Check: `packages/stage-ui/src/stores/modules/speech.test.ts` + +**Interfaces:** +- Consumes: all previous tasks. +- Produces: verified implementation ready for user review. + +- [ ] **Step 1: Run lint** + +Run: + +```bash +pnpm lint +``` + +Expected: PASS, or unrelated pre-existing errors documented with exact output. + +- [ ] **Step 2: Run typecheck** + +Run: + +```bash +pnpm type-check +``` + +Expected: PASS, or unrelated pre-existing errors documented with exact output. + +- [ ] **Step 3: Inspect final diff** + +Run: + +```bash +git diff -- packages/stage-pages/src/pages/settings/modules/speech.vue packages/stage-ui/src/stores/modules/speech.test.ts +``` + +Expected: diff only contains the Voice Pack model-option integration and related tests. diff --git a/packages/stage-pages/src/pages/settings/modules/speech.vue b/packages/stage-pages/src/pages/settings/modules/speech.vue index 45558bf7e..7ef767861 100644 --- a/packages/stage-pages/src/pages/settings/modules/speech.vue +++ b/packages/stage-pages/src/pages/settings/modules/speech.vue @@ -16,7 +16,7 @@ import { import { useAnalytics } from '@proj-airi/stage-ui/composables' import { OFFICIAL_SPEECH_PROVIDER_ID, OFFICIAL_SPEECH_STREAMING_PROVIDER_ID } from '@proj-airi/stage-ui/libs/providers/providers/official' import { useAiriCardStore, useVoicePacksStore } from '@proj-airi/stage-ui/stores' -import { useSpeechStore, voicePackForSpeechProvider } from '@proj-airi/stage-ui/stores/modules/speech' +import { useSpeechStore, VOICE_PACK_MODEL_ID, voicePackForSpeechProvider } from '@proj-airi/stage-ui/stores/modules/speech' import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers' import { FieldCheckbox, @@ -73,11 +73,15 @@ const audioUrl = ref('') const audioPlayer = ref(null) const errorMessage = ref('') -const supportsVoicePackSelection = computed(() => activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID) -const shouldShowVoicePackSection = computed(() => - supportsVoicePackSelection.value - && (isLoadingVoicePacks.value || voicePacksError.value != null || voicePacks.value.length > 0), -) +const VOICE_PACK_MODEL_OPTION = { + id: VOICE_PACK_MODEL_ID, + name: 'Voice Pack', + description: 'Server-curated voices', +} + +const isOfficialSpeechProvider = computed(() => activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID) +const shouldShowVoicePackModel = computed(() => isOfficialSpeechProvider.value && voicePacks.value.length > 0) +const isVoicePackModelSelected = computed(() => shouldShowVoicePackModel.value && activeSpeechModel.value === VOICE_PACK_MODEL_ID) const boundVoicePack = computed(() => voicePackForSpeechProvider(activeSpeechProvider.value, activeCard.value?.extensions.airi.modules.speech.voicePack), ) @@ -89,6 +93,22 @@ const selectableSpeechProvidersMetadata = computed(() => { ] }) +const displayedProviderModels = computed(() => { + if (!shouldShowVoicePackModel.value) + return providerModels.value + + return [VOICE_PACK_MODEL_OPTION, ...providerModels.value] +}) + +const displayedSpeechModel = computed({ + get: () => activeSpeechModel.value === VOICE_PACK_MODEL_ID && !shouldShowVoicePackModel.value + ? '' + : activeSpeechModel.value, + set: (value: string) => { + activeSpeechModel.value = value + }, +}) + function createVoicePackVoice(voicePack: VoicePackSnapshot): VoiceInfo { return { id: voicePack.voiceId, @@ -101,14 +121,93 @@ function createVoicePackVoice(voicePack: VoicePackSnapshot): VoiceInfo { } } -function formatCostMultiplier(multiplier: number) { - return `${Number.isInteger(multiplier) ? multiplier : multiplier.toFixed(2).replace(/\.?0+$/, '')}x` +function voicePackVoiceId(packId: string) { + return `voice-pack:${packId}` +} + +function packIdFromVoicePackVoiceId(voiceId: string) { + return voiceId.startsWith('voice-pack:') ? voiceId.slice('voice-pack:'.length) : null +} + +function createVoicePackPickerVoice(pack: (typeof voicePacks.value)[number]): VoiceInfo { + return { + id: voicePackVoiceId(pack.id), + name: pack.name, + description: pack.description ?? pack.name, + previewURL: '', + languages: [{ code: 'en', title: 'English' }], + provider: activeSpeechProvider.value, + gender: 'neutral', + } +} + +function createVoicePackSnapshotPickerVoice(voicePack: VoicePackSnapshot): VoiceInfo { + return { + id: voicePackVoiceId(voicePack.packId), + name: voicePack.name, + description: voicePack.name, + previewURL: '', + languages: [{ code: 'en', title: 'English' }], + provider: activeSpeechProvider.value, + gender: 'neutral', + } +} + +const displayedVoiceOptions = computed(() => { + if (isVoicePackModelSelected.value) { + const options = voicePacks.value.map(pack => ({ + id: voicePackVoiceId(pack.id), + name: pack.name, + description: pack.description ?? undefined, + previewURL: '', + customizable: false, + })) + const voicePack = boundVoicePack.value + const frozenVoiceId = voicePack ? voicePackVoiceId(voicePack.packId) : null + if (voicePack && frozenVoiceId && !options.some(option => option.id === frozenVoiceId)) { + options.unshift({ + id: frozenVoiceId, + name: voicePack.name, + description: voicePack.name, + previewURL: '', + customizable: false, + }) + } + return options + } + + return (availableVoices.value[activeSpeechProvider.value] ?? []) + .filter((voice) => { + if (!activeSpeechModel.value) + return true + return !voice.compatibleModels || voice.compatibleModels.includes(activeSpeechModel.value) + }) + .map(voice => ({ + id: voice.id, + name: voice.name, + description: voice.description, + previewURL: voice.previewURL, + customizable: false, + })) +}) + +function syncBoundVoicePackSelection() { + const voicePack = boundVoicePack.value + if (!shouldShowVoicePackModel.value || !voicePack) + return false + + activeSpeechModel.value = VOICE_PACK_MODEL_ID + activeSpeechVoiceId.value = voicePackVoiceId(voicePack.packId) + activeSpeechVoice.value = createVoicePackSnapshotPickerVoice(voicePack) + return true } /** * Resolves the current TTS model id for low-cardinality analytics payloads. */ function currentTtsModelId() { + if (isVoicePackModelSelected.value && boundVoicePack.value) + return boundVoicePack.value.ttsModelId return activeSpeechModel.value || 'unknown' } @@ -116,6 +215,9 @@ function currentTtsModelId() { * Classifies the selected voice without sending free-form provider config as a dimension. */ function currentVoiceType(voiceId: string, providerId = activeSpeechProvider.value, voicePack = boundVoicePack.value): VoiceType { + if (packIdFromVoicePackVoiceId(voiceId) != null) + return 'voice_pack' + if (voicePack?.voiceId === voiceId) return 'voice_pack' @@ -189,10 +291,20 @@ function selectSpeechProvider(providerId: string) { /** * Tracks explicit voice selection from catalog or custom input controls. */ -function selectSpeechVoice(voiceId: string | undefined) { +async function selectSpeechVoice(voiceId: string | undefined) { if (!voiceId) return + const voicePackId = packIdFromVoicePackVoiceId(voiceId) + if (isVoicePackModelSelected.value && voicePackId) { + const pack = voicePacks.value.find(item => item.id === voicePackId) + if (!pack) + return + + await bindVoicePack(pack) + return + } + trackVoiceSelected({ tts_provider_id: activeSpeechProvider.value || 'unknown', tts_model_id: currentTtsModelId(), @@ -231,6 +343,7 @@ function syncOpenAICompatibleSettings() { onMounted(async () => { await providersStore.loadModelsForConfiguredProviders() await voicePacksStore.load() + syncBoundVoicePackSelection() speechStore.ensureActiveSpeechModel() await speechStore.loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined) syncOpenAICompatibleSettings() @@ -240,7 +353,11 @@ async function bindVoicePack(pack: (typeof voicePacks.value)[number]) { const bound = airiCardStore.bindVoicePackToActiveCard(pack) if (!bound) return - await speechStore.loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined) + + activeSpeechModel.value = VOICE_PACK_MODEL_ID + activeSpeechVoiceId.value = voicePackVoiceId(pack.id) + activeSpeechVoice.value = createVoicePackPickerVoice(pack) + trackVoicePackBound({ tts_provider_id: activeSpeechProvider.value || 'unknown', tts_model_id: pack.ttsModelId, @@ -251,7 +368,9 @@ async function bindVoicePack(pack: (typeof voicePacks.value)[number]) { trackVoiceSelected({ tts_provider_id: activeSpeechProvider.value || 'unknown', tts_model_id: pack.ttsModelId, - ...voiceAnalyticsPayload(pack.voiceId, boundVoicePack.value), + voice_id: pack.voiceId, + voice_type: 'voice_pack', + voice_pack_id: pack.id, source: 'settings', }) } @@ -275,13 +394,40 @@ watch(activeSpeechProvider, async (newProvider, oldProvider) => { syncOpenAICompatibleSettings() }) -watch(activeSpeechModel, async () => { - if (activeSpeechProvider.value) { - await speechStore.loadVoicesForProvider(activeSpeechProvider.value, activeSpeechModel.value || undefined) - } +watch(boundVoicePack, () => { + syncBoundVoicePackSelection() +}) + +watch(voicePacks, () => { + syncBoundVoicePackSelection() +}) + +watch(activeSpeechModel, async (model) => { + if (!activeSpeechProvider.value) + return + + if (model === VOICE_PACK_MODEL_ID) + return + + activeSpeechVoiceId.value = '' + activeSpeechVoice.value = undefined + + await speechStore.loadVoicesForProvider(activeSpeechProvider.value, model || undefined) }) watch([activeSpeechProvider, activeSpeechModel, activeSpeechVoiceId], ([provider, model, voiceId]) => { + if (provider === OFFICIAL_SPEECH_PROVIDER_ID && model === VOICE_PACK_MODEL_ID) { + const voicePack = boundVoicePack.value + if (voicePack) { + airiCardStore.updateActiveCardSpeech({ + provider, + model: voicePack.ttsModelId, + voice_id: voicePack.voiceId, + }) + } + return + } + airiCardStore.updateActiveCardSpeech({ provider, model, voice_id: voiceId }) }) @@ -480,57 +626,6 @@ function handleDeleteProvider(providerId: string) {
- -

{{ t('settings.pages.modules.speech.sections.section.provider-voice-selection.title') }} @@ -608,7 +703,7 @@ function handleDeleteProvider(providerId: string) {

{{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.subtitle') }} - {{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.current_model_label') }} {{ activeSpeechModel }} + {{ t('settings.pages.modules.consciousness.sections.section.provider-model-selection.current_model_label') }} {{ displayedSpeechModel }}
@@ -650,7 +745,7 @@ function handleDeleteProvider(providerId: string) { -