Files
botfights/frontend/src/pages/SoundboardPage.vue
T

149 lines
5.9 KiB
Vue
Raw Normal View History

<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>