Back to Airi

TTS Voice Pack Model Option Implementation Plan

docs/superpowers/plans/2026-07-01-tts-voice-pack-model-option-plan.md

0.11.09.8 KB
Original Source

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 <template v-if="shouldShowVoicePackSection">...</template> 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.