docs/superpowers/plans/2026-07-01-tts-voice-pack-model-option-plan.md
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.
LLM_ROUTER_CONFIG model aliases.packages/stage-pages/src/pages/settings/modules/speech.vue
Voice Pack model option.packages/stage-ui/src/stores/modules/speech.ts
VOICE_PACK_MODEL_ID./audio/voices?model=voice-pack.packages/stage-ui/src/stores/modules/speech.test.ts
Files:
packages/stage-pages/src/pages/settings/modules/speech.vuepackages/stage-ui/src/stores/modules/speech.tsInterfaces:
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:
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:
const VOICE_PACK_MODEL_OPTION = {
id: VOICE_PACK_MODEL_ID,
name: 'Voice Pack',
description: 'Server-curated voices',
}
Add computed helpers:
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]
})
Delete the <template v-if="shouldShowVoicePackSection">...</template> block. Keep voicePacksStore.load() on mount.
Add helpers:
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,
}))
})
Update selectSpeechVoice so selecting a projected Voice Pack binds the server pack and tracks the underlying pack:
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',
})
}
voice-pack to /audio/voicesUpdate the active model watcher:
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)
})
Change providerModels usages in the model picker to displayedProviderModels.
Change the voice picker condition to:
v-else-if="activeSpeechProvider !== 'openai-compatible-audio-speech' && displayedVoiceOptions.length > 0"
Change the voice picker :voices binding to:
:voices="displayedVoiceOptions"
Change the manual voice input condition so it does not show for the Voice Pack model:
v-if="!isVoicePackModelSelected && (activeSpeechProvider === 'openai-compatible-audio-speech' || !availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0)"
Run:
pnpm -F @proj-airi/stage-pages typecheck
Expected: PASS, or unrelated pre-existing package errors documented with exact output.
Files:
packages/stage-pages/src/pages/settings/modules/speech.vuepackages/stage-ui/src/stores/modules/speech.test.tsInterfaces:
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:
const voicePack = boundVoicePack.value
if (voicePack) {
model = voicePack.ttsModelId
if (!voice || voice.id !== voicePack.voiceId)
voice = createVoicePackVoice(voicePack)
}
Confirm or add a test in packages/stage-ui/src/stores/modules/speech.test.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,
},
})
})
Run:
pnpm exec vitest run packages/stage-ui/src/stores/modules/speech.test.ts
Expected: PASS.
Files:
packages/stage-pages/src/pages/settings/modules/speech.vuepackages/stage-ui/src/stores/modules/speech.test.tsInterfaces:
Consumes: all previous tasks.
Produces: verified implementation ready for user review.
Step 1: Run lint
Run:
pnpm lint
Expected: PASS, or unrelated pre-existing errors documented with exact output.
Run:
pnpm type-check
Expected: PASS, or unrelated pre-existing errors documented with exact output.
Run:
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.