149 lines
5.9 KiB
Vue
149 lines
5.9 KiB
Vue
<script setup lang="ts">
|
|||
|
|
import { ref, computed, onMounted } from 'vue'
|
||
|
|
import { getVoiceMap, isKokoroReady, isKokoroLoading } from '../game/tts'
|
||
|
|
import { speak, ensureAudioContext } from '../game/sounds'
|
||
|
|
|
||
|
|
const voiceMap = getVoiceMap()
|
||
|
|
const allProfiles = Object.entries(voiceMap).map(([name, { voice, speed }]) => ({
|
||
|
|
name,
|
||
|
|
kokoroVoice: voice,
|
||
|
|
speed,
|
||
|
|
}))
|
||
|
|
|
||
|
|
const customText = ref('Devastating blow! That had to hurt!')
|
||
|
|
const filter = ref('')
|
||
|
|
const audioReady = ref(false)
|
||
|
|
const playing = ref<string | null>(null)
|
||
|
|
|
||
|
|
const filtered = computed(() => {
|
||
|
|
if (!filter.value) return allProfiles
|
||
|
|
const q = filter.value.toLowerCase()
|
||
|
|
return allProfiles.filter(p =>
|
||
|
|
p.name.includes(q) || p.kokoroVoice.includes(q)
|
||
|
|
)
|
||
|
|
})
|
||
|
|
|
||
|
|
// Group by category based on voice name prefix
|
||
|
|
const categories = computed(() => {
|
||
|
|
const groups: Record<string, typeof allProfiles> = {}
|
||
|
|
for (const p of filtered.value) {
|
||
|
|
let cat = 'Other'
|
||
|
|
if (['announcer', 'deep', 'smooth', 'question_reader', 'news'].includes(p.name)) cat = 'Authoritative'
|
||
|
|
else if (['hype', 'screamer', 'sportscaster', 'auctioneer', 'hyper', 'punk', 'drill', 'karen', 'terrified', 'power_up', 'wrestler_v'].includes(p.name)) cat = 'High Energy'
|
||
|
|
else if (['boomer', 'movie', 'demon_v', 'final_boss', 'boss_taunt', 'game_over', 'giant', 'mainframe'].includes(p.name)) cat = 'Deep / Menacing'
|
||
|
|
else if (['preacher', 'wizard_v', 'sensei', 'professor', 'ancient', 'opera'].includes(p.name)) cat = 'Calm / Wise'
|
||
|
|
else if (['chipmunk', 'baby', 'fairy', 'angel', 'tutorial', 'valley'].includes(p.name)) cat = 'Cute / High'
|
||
|
|
else if (['robot', 'ai_core', 'mech', 'android_v', 'siri', 'hal', 'dial_up', 'glitch', 'glitchbot'].includes(p.name)) cat = 'Robots'
|
||
|
|
else if (['posh', 'aussie', 'scottish', 'french', 'texan'].includes(p.name)) cat = 'Accents'
|
||
|
|
else if (['whisper', 'surfer', 'pirate_v', 'cowboy_v', 'ninja_v', 'alien_v', 'echo_v'].includes(p.name)) cat = 'Characters'
|
||
|
|
else if (['grandpa', 'grandma', 'crotchety'].includes(p.name)) cat = 'Old People'
|
||
|
|
else if (['drunk', 'sleepy', 'stoner', 'npc', 'conspiracy'].includes(p.name)) cat = 'Misc Characters'
|
||
|
|
if (!groups[cat]) groups[cat] = []
|
||
|
|
groups[cat].push(p)
|
||
|
|
}
|
||
|
|
return groups
|
||
|
|
})
|
||
|
|
|
||
|
|
async function initAudio() {
|
||
|
|
await ensureAudioContext()
|
||
|
|
audioReady.value = true
|
||
|
|
}
|
||
|
|
|
||
|
|
function playVoice(profileName: string) {
|
||
|
|
if (!audioReady.value) return
|
||
|
|
playing.value = profileName
|
||
|
|
speak(customText.value || 'Devastating blow! That had to hurt!', profileName, true)
|
||
|
|
setTimeout(() => { if (playing.value === profileName) playing.value = null }, 3000)
|
||
|
|
}
|
||
|
|
|
||
|
|
const samplePhrases = [
|
||
|
|
'Devastating blow! That had to hurt!',
|
||
|
|
'Round one! Fight!',
|
||
|
|
'K. O.! And the winner is...',
|
||
|
|
'What an incredible combo!',
|
||
|
|
'The crowd goes wild!',
|
||
|
|
'Is that all you got?',
|
||
|
|
'Satoshi would be proud!',
|
||
|
|
'Lightning fast attack!',
|
||
|
|
'Not your keys, not your coins!',
|
||
|
|
'Stack sats and throw hands!',
|
||
|
|
]
|
||
|
|
|
||
|
|
function randomPhrase() {
|
||
|
|
customText.value = samplePhrases[Math.floor(Math.random() * samplePhrases.length)]
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<div class="min-h-screen bg-black text-green-400 p-4 sm:p-8 font-mono">
|
||
|
|
<h1 class="text-2xl sm:text-3xl font-bold text-cyan-400 mb-2">VOICE SOUNDBOARD</h1>
|
||
|
|
<p class="text-zinc-500 text-sm mb-6">{{ allProfiles.length }} voice profiles. Click to preview.</p>
|
||
|
|
|
||
|
|
<!-- Audio init banner -->
|
||
|
|
<div v-if="!audioReady" class="mb-6">
|
||
|
|
<button
|
||
|
|
@click="initAudio"
|
||
|
|
class="px-6 py-3 bg-cyan-600 hover:bg-cyan-500 text-black font-bold rounded text-lg transition-colors"
|
||
|
|
>
|
||
|
|
CLICK TO ENABLE AUDIO
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Status -->
|
||
|
|
<div v-if="audioReady" class="mb-4 flex items-center gap-3 text-sm">
|
||
|
|
<span v-if="isKokoroReady()" class="text-green-400">Kokoro TTS: READY</span>
|
||
|
|
<span v-else-if="isKokoroLoading()" class="text-yellow-400">Kokoro TTS: Loading model...</span>
|
||
|
|
<span v-else class="text-zinc-500">Kokoro TTS: Not loaded (using Web Speech fallback)</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Custom text + filter -->
|
||
|
|
<div class="flex flex-col sm:flex-row gap-3 mb-6">
|
||
|
|
<div class="flex-1 flex gap-2">
|
||
|
|
<input
|
||
|
|
v-model="customText"
|
||
|
|
class="flex-1 bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-green-400 text-sm focus:border-cyan-500 focus:outline-none"
|
||
|
|
placeholder="Type custom text to speak..."
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
@click="randomPhrase"
|
||
|
|
class="px-3 py-2 bg-zinc-800 hover:bg-zinc-700 border border-zinc-600 rounded text-xs text-zinc-400 transition-colors whitespace-nowrap"
|
||
|
|
>
|
||
|
|
Random
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<input
|
||
|
|
v-model="filter"
|
||
|
|
class="sm:w-48 bg-zinc-900 border border-zinc-700 rounded px-3 py-2 text-green-400 text-sm focus:border-cyan-500 focus:outline-none"
|
||
|
|
placeholder="Filter voices..."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Voice grid by category -->
|
||
|
|
<div v-for="(profiles, category) in categories" :key="category" class="mb-8">
|
||
|
|
<h2 class="text-lg font-bold text-yellow-400 mb-3 border-b border-zinc-800 pb-1">{{ category }}</h2>
|
||
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||
|
|
<button
|
||
|
|
v-for="p in profiles"
|
||
|
|
:key="p.name"
|
||
|
|
:disabled="!audioReady"
|
||
|
|
@click="playVoice(p.name)"
|
||
|
|
class="text-left px-3 py-2 rounded border transition-all"
|
||
|
|
:class="[
|
||
|
|
playing === p.name
|
||
|
|
? 'bg-cyan-900/40 border-cyan-500 text-cyan-300'
|
||
|
|
: 'bg-zinc-900/60 border-zinc-800 hover:border-zinc-600 hover:bg-zinc-800/60',
|
||
|
|
!audioReady && 'opacity-40 cursor-not-allowed'
|
||
|
|
]"
|
||
|
|
>
|
||
|
|
<div class="font-bold text-sm" :class="playing === p.name ? 'text-cyan-300' : 'text-green-400'">
|
||
|
|
{{ p.name }}
|
||
|
|
</div>
|
||
|
|
<div class="text-xs text-zinc-500 mt-0.5">
|
||
|
|
{{ p.kokoroVoice }} @ {{ p.speed }}x
|
||
|
|
</div>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</template>
|