feat(stage-ui): voice card, audio spectrum

This commit is contained in:
Neko Ayaka
2025-03-18 02:01:09 +08:00
parent 79d0afa7de
commit 0e2a7eff8b
21 changed files with 1524 additions and 61 deletions
@@ -2,8 +2,8 @@
import {
FieldCheckbox,
FieldRange,
RadioCardDetailManySelect,
RadioCardSimple,
VoiceCardManySelect,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
@@ -68,6 +68,12 @@ function updateSSMLExample() {
</voice>
</speak>`
}
// Add this function to handle voice selection from the preview player
function handleVoiceSelection(value: string) {
voiceId.value = value
updateSSMLExample()
}
</script>
<template>
@@ -104,8 +110,13 @@ function updateSSMLExample() {
min-w-0 of-x-scroll scroll-smooth role="radiogroup"
>
<RadioCardSimple
v-for="metadata in availableProvidersMetadata" :id="metadata.id" :key="metadata.id"
v-model="activeSpeechProvider" name="speech-provider" :value="metadata.id" :title="metadata.localizedName"
v-for="metadata in availableProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeSpeechProvider"
name="speech-provider"
:value="metadata.id"
:title="metadata.localizedName"
:description="metadata.localizedDescription"
/>
</fieldset>
@@ -164,14 +175,17 @@ function updateSSMLExample() {
v-else-if="availableVoices[activeSpeechProvider] && availableVoices[activeSpeechProvider].length > 0"
class="space-y-6"
>
<RadioCardDetailManySelect
v-model="voiceId" v-model:search-query="voiceSearchQuery"
:items="availableVoices[activeSpeechProvider]?.map(voice => ({
<VoiceCardManySelect
v-model:search-query="voiceSearchQuery"
:voices="availableVoices[activeSpeechProvider]?.map(voice => ({
id: voice.name,
name: voice.name,
description: voice.description,
customizable: true,
}))" :searchable="true"
previewURL: voice.previewURL,
customizable: false,
}))"
:selected-voice-id="voiceId"
:searchable="true"
:search-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.search_voices_placeholder')"
:search-no-results-title="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices')"
:search-no-results-description="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices_description')"
@@ -179,6 +193,9 @@ function updateSSMLExample() {
:custom-input-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.custom_voice_placeholder')"
:expand-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.show_more')"
:collapse-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.show_less')"
:play-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.play_sample')"
:pause-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.pause')"
@update:selected-voice-id="handleVoiceSelection"
@update:custom-value="updateCustomVoiceName"
/>
+8 -6
View File
@@ -61,14 +61,12 @@ settings:
modules:
consciousness:
description: 人格设定、使用的模型等配置
title: 意识
sections:
section:
provider-model-selection:
title: 模型
description: 为意识选择合适的 LLM 提供商
collapse: 折叠
custom_model_placeholder: 输入自订模型名字
description: 为意识选择合适的 LLM 提供商
error: 获取出错啦
expand: 展开
loading: 获取模型中...
@@ -85,6 +83,8 @@ settings:
show_less: 显示更多
show_more: 收起
subtitle: 选择一个模型
title: 模型
title: 意识
description: 思维,视觉,言语综合,游戏等
gaming-factorio:
description: 一起玩 Factorio
@@ -106,24 +106,26 @@ settings:
title: Discord
speech:
description: 语音合成
title: 发声
sections:
section:
provider-voice-selection:
description: 选择合适的语音提供商
title: 提供商
custom_model_placeholder: 输入指定模型名字...
custom_voice_placeholder: 输入指定声线 ID...
description: 选择合适的语音提供商
no_models: 没有任何可用的模型
no_models_description: 该提供商的模型列表没有返回任何的数据
no_voices: 没有任何可用的声线
no_voices_description: 该提供商的声线列表没有返回任何的数据
pause: Pause
play_sample: Play
search_models_placeholder: 搜索模型...
search_models_results: 找到 {count} / {total} 个模型
search_voices_placeholder: 搜索声线...
search_voices_results: 找到 {count} / {total} 个声线
show_less: 显示更少
show_more: 显示更多
title: 提供商
title: 发声
title: 机体模块
vision:
description: 视觉
@@ -2,8 +2,8 @@
import {
FieldCheckbox,
FieldRange,
RadioCardDetailManySelect,
RadioCardSimple,
VoiceCardManySelect,
} from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
@@ -68,6 +68,12 @@ function updateSSMLExample() {
</voice>
</speak>`
}
// Add this function to handle voice selection from the preview player
function handleVoiceSelection(value: string) {
voiceId.value = value
updateSSMLExample()
}
</script>
<template>
@@ -104,8 +110,13 @@ function updateSSMLExample() {
min-w-0 of-x-scroll scroll-smooth role="radiogroup"
>
<RadioCardSimple
v-for="metadata in availableProvidersMetadata" :id="metadata.id" :key="metadata.id"
v-model="activeSpeechProvider" name="speech-provider" :value="metadata.id" :title="metadata.localizedName"
v-for="metadata in availableProvidersMetadata"
:id="metadata.id"
:key="metadata.id"
v-model="activeSpeechProvider"
name="speech-provider"
:value="metadata.id"
:title="metadata.localizedName"
:description="metadata.localizedDescription"
/>
</fieldset>
@@ -164,15 +175,17 @@ function updateSSMLExample() {
v-else-if="availableVoices[activeSpeechProvider] && availableVoices[activeSpeechProvider].length > 0"
class="space-y-6"
>
<RadioCardDetailManySelect
v-model="voiceId"
<VoiceCardManySelect
v-model:search-query="voiceSearchQuery"
:items="availableVoices[activeSpeechProvider]?.map(voice => ({
:voices="availableVoices[activeSpeechProvider]?.map(voice => ({
id: voice.name,
name: voice.name,
description: voice.description,
customizable: true,
}))" :searchable="true"
previewURL: voice.previewURL,
customizable: false,
}))"
:selected-voice-id="voiceId"
:searchable="true"
:search-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.search_voices_placeholder')"
:search-no-results-title="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices')"
:search-no-results-description="t('settings.pages.modules.speech.sections.section.provider-voice-selection.no_voices_description')"
@@ -180,6 +193,9 @@ function updateSSMLExample() {
:custom-input-placeholder="t('settings.pages.modules.speech.sections.section.provider-voice-selection.custom_voice_placeholder')"
:expand-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.show_more')"
:collapse-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.show_less')"
:play-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.play_sample')"
:pause-button-text="t('settings.pages.modules.speech.sections.section.provider-voice-selection.pause')"
@update:selected-voice-id="handleVoiceSelection"
@update:custom-value="updateCustomVoiceName"
/>
@@ -0,0 +1,106 @@
<script setup lang="ts">
withDefaults(defineProps<{
id: string
name: string
value: string
title: string
deprecated?: boolean
}>(), {
deprecated: false,
})
const modelValue = defineModel<string>({ required: true })
</script>
<template>
<label
:key="id"
class="form_radio relative flex cursor-pointer items-start rounded-xl p-3 pr-[20px]"
transition="all duration-200 ease-in-out"
border="2 solid"
:class="[
modelValue === value
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-100 dark:border-primary-900 hover:border-primary-500/30 dark:hover:border-primary-400/30'
: 'bg-white dark:bg-neutral-900/20 border-neutral-100 dark:border-neutral-700 hover:border-primary-500/30 dark:hover:border-primary-400/30',
modelValue === value
? 'form_radio-active'
: '',
deprecated ? 'opacity-60' : '',
]"
>
<input
v-model="modelValue"
:checked="modelValue === value"
type="radio"
:name="name"
:value="value"
class="absolute opacity-0"
>
<div class="relative mr-3 mt-0.5 flex-shrink-0">
<div
class="size-5 border-2 rounded-full transition-colors duration-200"
:class="[
modelValue === value
? 'border-primary-500 dark:border-primary-400'
: 'border-neutral-300 dark:border-neutral-600',
]"
>
<div
class="absolute left-1/2 top-1/2 size-3 rounded-full transition-opacity duration-200 -translate-x-1/2 -translate-y-1/2"
:class="[
modelValue === value
? 'opacity-100 bg-primary-500 dark:bg-primary-400'
: 'opacity-0',
]"
/>
</div>
</div>
<div class="w-full flex flex-col gap-2">
<div class="flex items-center">
<span
class="line-clamp-1 font-medium"
:class="[
modelValue === value
? 'text-neutral-700 dark:text-neutral-300'
: 'text-neutral-700 dark:text-neutral-400',
]"
>
{{ title }}
</span>
</div>
</div>
</label>
</template>
<style scoped>
.form_radio {
position: relative;
overflow: hidden;
}
.form_radio::before {
--at-apply: 'bg-gradient-to-r from-primary-500/0 to-primary-500/0 dark:from-primary-400/0 dark:to-primary-400/0';
content: '';
position: absolute;
inset: 0;
z-index: 0;
width: 25%;
height: 100%;
transition: all 0.35s ease-in-out;
mask-image: linear-gradient(120deg, white 100%);
opacity: 0;
}
.form_radio:hover::before,
.form_radio._hover::before {
--at-apply: 'bg-gradient-to-r from-primary-500/20 via-primary-500/10 to-transparent dark:from-primary-400/20 dark:via-primary-400/10 dark:to-transparent';
width: 85%;
opacity: 1;
}
.form_radio-active::before {
--at-apply: 'bg-gradient-to-r from-primary-500/20 via-primary-500/10 to-transparent dark:from-primary-400/20 dark:via-primary-400/10 dark:to-transparent';
width: 85%;
opacity: 0.5;
}
</style>
@@ -19,6 +19,7 @@ const modelValue = ref('test')
<Variant id="default" title="Default">
<RadioCardDetail
id="test"
v-model="modelValue"
value="non-selected"
title="Test"
description="Test description"
@@ -28,7 +29,6 @@ const modelValue = ref('test')
:show-custom-input="true"
custom-input-value="Test input value"
custom-input-placeholder="Test input placeholder"
:model-value="modelValue"
name="radio-card-detail-many-select"
class="scroll-snap-align-start"
/>
@@ -37,6 +37,7 @@ const modelValue = ref('test')
<Variant id="hover" title="Hover">
<RadioCardDetail
id="test"
v-model="modelValue"
value="non-selected"
title="Test"
description="Test description"
@@ -46,7 +47,6 @@ const modelValue = ref('test')
:show-custom-input="true"
custom-input-value="Test input value"
custom-input-placeholder="Test input placeholder"
:model-value="modelValue"
name="radio-card-detail-many-select"
class="scroll-snap-align-start _hover"
/>
@@ -55,6 +55,7 @@ const modelValue = ref('test')
<Variant id="default-long-description" title="Long description">
<RadioCardDetail
id="test"
v-model="modelValue"
value="non-selected"
title="Test"
:description="'Test description'.repeat(10)"
@@ -64,7 +65,6 @@ const modelValue = ref('test')
:show-custom-input="true"
custom-input-value="Test input value"
custom-input-placeholder="Test input placeholder"
:model-value="modelValue"
name="radio-card-detail-many-select"
class="scroll-snap-align-start"
/>
@@ -73,6 +73,7 @@ const modelValue = ref('test')
<Variant id="hover-long-description" title="Hover Long description">
<RadioCardDetail
id="test"
v-model="modelValue"
value="non-selected"
title="Test"
:description="'Test description'.repeat(10)"
@@ -82,7 +83,6 @@ const modelValue = ref('test')
:show-custom-input="true"
custom-input-value="Test input value"
custom-input-placeholder="Test input placeholder"
:model-value="modelValue"
name="radio-card-detail-many-select"
class="scroll-snap-align-start _hover"
/>
@@ -91,6 +91,7 @@ const modelValue = ref('test')
<Variant id="default-selected" title="Default Selected">
<RadioCardDetail
id="test"
v-model="modelValue"
value="test"
title="Test"
description="Test description"
@@ -100,7 +101,6 @@ const modelValue = ref('test')
:show-custom-input="true"
custom-input-value="Test input value"
custom-input-placeholder="Test input placeholder"
:model-value="modelValue"
name="radio-card-detail-many-select"
class="scroll-snap-align-start"
/>
@@ -109,6 +109,7 @@ const modelValue = ref('test')
<Variant id="hover-selected" title="Hover Selected">
<RadioCardDetail
id="test"
v-model="modelValue"
value="test"
title="Test"
description="Test description"
@@ -118,7 +119,6 @@ const modelValue = ref('test')
:show-custom-input="true"
custom-input-value="Test input value"
custom-input-placeholder="Test input placeholder"
:model-value="modelValue"
name="radio-card-detail-many-select"
class="scroll-snap-align-start _hover"
/>
@@ -127,6 +127,7 @@ const modelValue = ref('test')
<Variant id="default-selected-long-description" title="Long description Selected">
<RadioCardDetail
id="test"
v-model="modelValue"
value="test"
title="Test"
:description="'Test description'.repeat(10)"
@@ -136,7 +137,6 @@ const modelValue = ref('test')
:show-custom-input="true"
custom-input-value="Test input value"
custom-input-placeholder="Test input placeholder"
:model-value="modelValue"
name="radio-card-detail-many-select"
class="scroll-snap-align-start"
/>
@@ -145,6 +145,7 @@ const modelValue = ref('test')
<Variant id="hover-selected-long-description" title="Hover Long description Selected">
<RadioCardDetail
id="test"
v-model="modelValue"
value="test"
title="Test"
:description="'Test description'.repeat(10)"
@@ -154,7 +155,6 @@ const modelValue = ref('test')
:show-custom-input="true"
custom-input-value="Test input value"
custom-input-placeholder="Test input placeholder"
:model-value="modelValue"
name="radio-card-detail-many-select"
class="scroll-snap-align-start _hover"
/>
@@ -2,6 +2,7 @@
import { ref } from 'vue'
import TransitionVertical from '../../TransitionVertical.vue'
import Input from '../Input/Input.vue'
withDefaults(defineProps<{
id: string
@@ -9,7 +10,6 @@ withDefaults(defineProps<{
value: string
title: string
description?: string
modelValue: string
deprecated?: boolean
showExpandCollapse?: boolean
expandCollapseThreshold?: number
@@ -25,10 +25,7 @@ withDefaults(defineProps<{
showCustomInput: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'update:customInputValue', value: string): void
}>()
const modelValue = defineModel<string>({ required: true })
// Track if description is expanded
const isExpanded = ref(false)
@@ -37,12 +34,6 @@ const isExpanded = ref(false)
function toggleExpansion() {
isExpanded.value = !isExpanded.value
}
// Update custom input value
function updateCustomInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:customInputValue', target.value)
}
</script>
<template>
@@ -62,12 +53,12 @@ function updateCustomInput(event: Event) {
]"
>
<input
v-model="modelValue"
:checked="modelValue === value"
type="radio"
:name="name"
:value="value"
class="absolute opacity-0"
@change="$emit('update:modelValue', value)"
>
<div class="relative mr-3 mt-0.5 flex-shrink-0">
<div
@@ -153,13 +144,12 @@ function updateCustomInput(event: Event) {
<!-- Custom model input field -->
<div v-if="showCustomInput && modelValue === value" class="mt-2">
<input
:value="customInputValue"
<Input
v-model="modelValue"
type="text"
class="w-full border border-neutral-300 rounded bg-white px-2 py-1 text-sm dark:border-neutral-700 dark:bg-neutral-900"
:placeholder="customInputPlaceholder"
@input="updateCustomInput"
>
/>
</div>
</div>
</label>
@@ -172,6 +162,7 @@ function updateCustomInput(event: Event) {
}
.form_radio-card-detail::before {
pointer-events: none;
--at-apply: 'bg-gradient-to-r from-primary-500/0 to-primary-500/0 dark:from-primary-400/0 dark:to-primary-400/0';
content: '';
position: absolute;
@@ -19,22 +19,22 @@ const modelValue = ref('test')
<Variant id="default" title="Default">
<RadioCardSimple
id="test"
v-model="modelValue"
name="radio-card-simple"
value="non-selected"
title="Test"
description="Test description"
:model-value="modelValue"
/>
</Variant>
<Variant id="hover" title="Hover">
<RadioCardSimple
id="test"
v-model="modelValue"
name="radio-card-simple"
value="non-selected"
title="Test"
description="Test description"
:model-value="modelValue"
class="_hover"
/>
</Variant>
@@ -42,22 +42,22 @@ const modelValue = ref('test')
<Variant id="default-selected" title="Default Selected">
<RadioCardSimple
id="test"
v-model="modelValue"
name="radio-card-simple"
value="test"
title="Test"
description="Test description"
:model-value="modelValue"
/>
</Variant>
<Variant id="hover-selected" title="Hover Selected">
<RadioCardSimple
id="test"
v-model="modelValue"
name="radio-card-simple"
value="test"
title="Test"
description="Test description"
:model-value="modelValue"
class="_hover"
/>
</Variant>
@@ -5,12 +5,9 @@ defineProps<{
value: string
title: string
description?: string
modelValue: string
}>()
defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const modelValue = defineModel<string>({ required: true })
</script>
<template>
@@ -31,12 +28,12 @@ defineEmits<{
block min-w-50 w-fit cursor-pointer items-start rounded-xl p-4 text-left
>
<input
v-model="modelValue"
:checked="modelValue === value"
type="radio"
:name="name"
:value="value"
class="[&:checked+div]:border-primary-500 dark:[&:checked+div]:border-primary-400 absolute opacity-0 [&:checked+div_.radio-dot]:opacity-100"
@change="$emit('update:modelValue', value)"
>
<div
class="radio-circle absolute left-2 top-2 size-5 rounded-full"
@@ -1,2 +1,3 @@
export { default as Radio } from './Radio.vue'
export { default as RadioCardDetail } from './RadioCardDetail.vue'
export { default as RadioCardSimple } from './RadioCardSimple.vue'
@@ -13,7 +13,6 @@ interface Item {
interface Props {
items: Item[]
modelValue: string
searchable?: boolean
searchPlaceholder?: string
searchNoResultsTitle?: string
@@ -36,10 +35,11 @@ const props = withDefaults(defineProps<Props>(), {
})
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:customValue': [value: string]
}>()
const modelValue = defineModel<string>({ required: true })
const searchQuery = ref('')
const isListExpanded = ref(false)
const customValue = ref('')
@@ -115,6 +115,7 @@ function updateCustomValue(value: string) {
v-for="item in filteredItems"
:id="item.id"
:key="item.id"
v-model="modelValue"
:value="item.id"
:title="item.name"
:description="item.description"
@@ -124,10 +125,8 @@ function updateCustomValue(value: string) {
:show-custom-input="item.customizable"
:custom-input-value="customValue"
:custom-input-placeholder="customInputPlaceholder"
:model-value="modelValue"
name="radio-card-detail-many-select"
class="scroll-snap-align-start"
@update:model-value="emit('update:modelValue', $event)"
@update:custom-input-value="updateCustomValue($event)"
/>
</div>
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue'
import VoiceCard from './VoiceCard.vue'
// Sample voice data
const selectedVoiceId = ref('lNxY9WuCBCZCISASyJ55')
const currentlyPlayingId = ref<string>()
const customInputValue = ref('')
const audioElement = ref<HTMLAudioElement>()
const mediaStream = ref<MediaStream | null>(null)
// Mock voices with different configurations
const voice = computed(() => ({
id: 'lNxY9WuCBCZCISASyJ55',
name: 'Myriam',
previewURL: 'https://storage.googleapis.com/eleven-public-prod/MCJE2vSmnChGnvdoSpbNO0dcNQw2/voices/MRCOs26Xkzk5vmsL1Q0D/b0a09f0d-6a02-486f-af95-94a0e5306dbd.mp3',
customizable: true,
labels: {
gender: 'Female',
age: 'Young',
accent: 'American',
},
languages: [
{ name: 'English', code: 'en-US' },
],
}))
// Toggle playback function
function togglePlayback(voice: { id: string }) {
if (currentlyPlayingId.value === voice.id) {
stopAudio()
}
else {
playAudio(voice.id)
}
}
// Play audio and create stream for visualization
function playAudio(voiceId: string) {
// Stop any currently playing audio
if (audioElement.value && !audioElement.value.paused) {
stopAudio()
}
// Create new audio element
audioElement.value = new Audio(voice.value.previewURL)
audioElement.value.crossOrigin = 'anonymous' // Important for CORS
// Set up audio context and stream when audio can play
audioElement.value.oncanplay = () => {
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
// Create audio analyzer for visualization
const analyzer = audioContext.createAnalyser()
analyzer.fftSize = 2048
// Create oscillator as a workaround for CORS restrictions
// This creates a synthetic audio stream that we can visualize
const oscillator = audioContext.createOscillator()
oscillator.frequency.value = 440 // Default frequency
// Create media stream destination for visualization
const destination = audioContext.createMediaStreamDestination()
// Connect oscillator to analyzer and destination
oscillator.connect(analyzer)
analyzer.connect(destination)
// Start the oscillator
oscillator.start()
// Set the media stream for visualization
mediaStream.value = destination.stream
// Start audio playback
audioElement.value?.play()
// Update playing state
currentlyPlayingId.value = voiceId
// Set up audio time update to modulate the oscillator
// This creates a visualization that roughly follows the audio
audioElement.value!.ontimeupdate = () => {
// Modulate oscillator frequency based on current time
// This is just for visual effect and doesn't represent the actual audio spectrum
if (oscillator && audioElement.value) {
const time = audioElement.value.currentTime
const duration = audioElement.value.duration || 1
const progress = time / duration
// Create some variation in the visualization
oscillator.frequency.value = 220 + (880 * Math.sin(progress * Math.PI * 2))
}
}
// Handle audio ending
audioElement.value!.onended = () => {
stopAudio()
}
}
catch (error) {
console.error('Failed to create audio stream:', error)
// Fallback to just playing audio without visualization
currentlyPlayingId.value = voiceId
}
}
// Handle load errors
audioElement.value.onerror = () => {
console.error('Error loading audio')
currentlyPlayingId.value = undefined
}
// Start loading the audio
audioElement.value.load()
}
// Stop audio playback and clean up
function stopAudio() {
if (audioElement.value != null) {
audioElement.value.pause()
audioElement.value = undefined
}
mediaStream.value = null
currentlyPlayingId.value = undefined
}
// Select voice function
function selectVoice(voiceId: string) {
selectedVoiceId.value = voiceId
}
// Clean up on unmount
onBeforeUnmount(() => {
stopAudio()
})
</script>
<template>
<Story
title="Voice Card"
group="form"
:layout="{ type: 'grid', width: 400 }"
>
<template #controls>
<ThemeColorsHueControl />
</template>
<Variant
id="default"
title="Default Voice Card"
>
<div class="w-full">
<VoiceCard
:voice="voice"
:selected-voice-id="selectedVoiceId"
:currently-playing-id="currentlyPlayingId"
:audio-stream="mediaStream"
@select="selectVoice"
@toggle-playback="togglePlayback"
@update:model-value="value => customInputValue = value"
/>
<div class="mt-4 text-sm text-neutral-500">
<p>This demo shows a voice card with audio playback and visualization.</p>
<p class="mt-1">
The visualization is synthetic and doesn't represent the actual audio spectrum due to CORS restrictions.
</p>
</div>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,268 @@
<script setup lang="ts">
import { computed } from 'vue'
import AudioSpectrum from '../Gadgets/AudioSpectrum.vue'
import AudioSpectrumVisualizer from '../Gadgets/AudioSpectrumVisualizer.vue'
import TransitionVertical from '../TransitionVertical.vue'
import Input from './Input/Input.vue'
interface VoiceLanguage {
name: string
code: string
}
interface Voice {
id: string
name: string
description?: string
previewURL?: string
preview_audio_url?: string
deprecated?: boolean
customizable?: boolean
labels?: {
accent?: string
age?: string
gender?: string
type?: string
}
languages?: VoiceLanguage[]
tags?: string[]
}
interface Props {
voice: Voice
selectedVoiceId: string
currentlyPlayingId?: string
customInputPlaceholder?: string
showVisualizer?: boolean
audioStream?: MediaStream | null
}
const props = withDefaults(defineProps<Props>(), {
customInputPlaceholder: 'Enter custom voice name',
showVisualizer: true,
audioStream: null,
})
const emit = defineEmits<{
'select': [voiceId: string]
'togglePlayback': [voice: Voice]
'update:modelValue': [value: string]
}>()
const modelValue = computed({
get: () => '',
set: value => emit('update:modelValue', value),
})
// Get preview URL from either field
function getPreviewUrl(voice: Voice): string | undefined {
return voice.previewURL || voice.preview_audio_url
}
// Format voice attributes for display
function formatVoiceAttributes(voice: Voice): string[] {
const attributes = []
// Add gender if available
if (voice.labels?.gender) {
attributes.push(voice.labels.gender)
}
// Add age if available
if (voice.labels?.age) {
attributes.push(voice.labels.age)
}
// Add accent if available
if (voice.labels?.accent) {
attributes.push(voice.labels.accent)
}
// Add languages if available
if (voice.languages && voice.languages.length > 0) {
const languageNames = voice.languages.map(lang => lang.name).join(', ')
attributes.push(languageNames)
}
return attributes
}
function selectVoice() {
emit('select', props.voice.id)
}
function togglePlayback() {
emit('togglePlayback', props.voice)
}
</script>
<template>
<div
class="scroll-snap-align-start voice-card relative flex flex-col overflow-hidden rounded-xl"
transition="all duration-200 ease-in-out"
border="2 solid"
:class="[
selectedVoiceId === voice.id
? 'bg-primary-50 dark:bg-primary-900/20 border-primary-100 dark:border-primary-900 hover:border-primary-500/30 dark:hover:border-primary-400/30'
: 'bg-white dark:bg-neutral-900/20 border-neutral-100 dark:border-neutral-700 hover:border-primary-500/30 dark:hover:border-primary-400/30',
voice.deprecated ? 'opacity-60' : '',
]"
>
<!-- Voice info section -->
<div class="p-3">
<div class="flex items-start">
<!-- Radio button -->
<div class="relative mr-3 mt-0.5 flex-shrink-0" @click="selectVoice">
<div
class="size-5 cursor-pointer border-2 rounded-full transition-colors duration-200"
:class="[
selectedVoiceId === voice.id
? 'border-primary-500 dark:border-primary-400'
: 'border-neutral-300 dark:border-neutral-600',
]"
>
<div
class="absolute left-1/2 top-1/2 size-3 rounded-full transition-opacity duration-200 -translate-x-1/2 -translate-y-1/2"
:class="[
selectedVoiceId === voice.id
? 'opacity-100 bg-primary-500 dark:bg-primary-400'
: 'opacity-0',
]"
/>
</div>
</div>
<!-- Audio preview button -->
<button
v-if="getPreviewUrl(voice)"
absolute right-0 top="0" z-100
class="translate-x-[-50%] translate-y-[50%]"
:class="[
currentlyPlayingId === voice.id
? 'text-white dark:text-white'
: '',
]"
@click="togglePlayback"
>
<div v-if="currentlyPlayingId === voice.id" i-solar:pause-circle-bold-duotone class="text-xl" />
<div v-else i-solar:play-circle-bold-duotone class="text-xl" />
</button>
<div
v-else
class="mt-auto w-full flex items-center justify-center bg-neutral-50 py-3 text-xs text-neutral-400 italic dark:bg-neutral-800/50 dark:text-neutral-600"
>
No preview available
</div>
<!-- Voice info -->
<div class="flex-1 cursor-pointer" @click="selectVoice">
<div class="flex items-center">
<span
class="line-clamp-1 font-medium"
:class="[
selectedVoiceId === voice.id
? 'text-neutral-700 dark:text-neutral-300'
: 'text-neutral-700 dark:text-neutral-400',
]"
>
{{ voice.name }}
</span>
</div>
<!-- Voice attributes (gender, age, accent, etc.) -->
<div v-if="formatVoiceAttributes(voice).length > 0" class="mt-1 flex flex-wrap gap-1">
<span
v-for="(attribute, attrIndex) in formatVoiceAttributes(voice)"
:key="attrIndex"
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-xs"
:class="[
selectedVoiceId === voice.id
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
]"
>
{{ attribute }}
</span>
</div>
<!-- Custom input field for selected voice -->
<div v-if="voice.customizable && selectedVoiceId === voice.id" class="mt-3">
<Input
v-model="modelValue"
type="text"
class="w-full border border-neutral-300 rounded bg-white px-2 py-1 text-sm dark:border-neutral-700 dark:bg-neutral-900"
:placeholder="customInputPlaceholder"
/>
</div>
<!-- Voice description -->
<div
v-if="voice.description" class="line-clamp-2 mt-1 text-xs"
:class="[
selectedVoiceId === voice.id
? 'text-neutral-600 dark:text-neutral-400'
: 'text-neutral-500 dark:text-neutral-500',
]"
>
{{ voice.description }}
</div>
</div>
</div>
</div>
<!-- Audio visualizer section -->
<div relative>
<TransitionVertical>
<div
v-if="showVisualizer && currentlyPlayingId === voice.id && audioStream"
class="h-16 px-3 pb-2"
>
<AudioSpectrum
v-slot="{ frequencies }"
:stream="audioStream"
:bars="24"
:min-freq="60"
:max-freq="4000"
>
<AudioSpectrumVisualizer
:frequencies="frequencies"
:bars-class="selectedVoiceId === voice.id
? 'bg-primary-500 dark:bg-primary-400'
: 'bg-neutral-400 dark:bg-neutral-600'"
/>
</AudioSpectrum>
</div>
</TransitionVertical>
</div>
</div>
</template>
<style scoped>
.voice-card {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.voice-card::before {
pointer-events: none;
--at-apply: 'bg-gradient-to-r from-primary-500/0 to-primary-500/0 dark:from-primary-400/0 dark:to-primary-400/0';
content: '';
position: absolute;
inset: 0;
z-index: 0;
width: 25%;
height: 100%;
transition: all 0.35s ease-in-out;
mask-image: linear-gradient(120deg, white 100%);
opacity: 0;
}
.voice-card:hover::before {
--at-apply: 'bg-gradient-to-r from-primary-500/20 via-primary-500/10 to-transparent dark:from-primary-400/20 dark:via-primary-400/10 dark:to-transparent';
width: 85%;
opacity: 1;
}
</style>
@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ref } from 'vue'
import VoiceCardManySelect from './VoiceCardManySelect.vue'
const selectedVoiceId = ref('voice1')
const customValue = ref('')
const searchQuery = ref('')
// Sample voice data
const voices = [
{
id: 'lNxY9WuCBCZCISASyJ55',
name: 'Myriam',
previewURL: 'https://storage.googleapis.com/eleven-public-prod/MCJE2vSmnChGnvdoSpbNO0dcNQw2/voices/MRCOs26Xkzk5vmsL1Q0D/b0a09f0d-6a02-486f-af95-94a0e5306dbd.mp3',
customizable: true,
labels: {
gender: 'Female',
age: 'Young',
accent: 'American',
},
languages: [
{ name: 'English', code: 'en-US' },
],
},
{
id: 'dLhSyo03JRp5WkGpUlz1',
name: 'Camilla_KM',
previewURL: 'https://storage.googleapis.com/eleven-public-prod/eOs5vIk28wffcUZAjlcI7i8gBc22/voices/0KK3BzieIeKs5Ripqjpj/2ce47169-01fa-4828-bb61-272f1683b83e.mp3',
customizable: true,
labels: {
gender: 'Female',
age: 'Young',
accent: 'American',
},
languages: [
{ name: 'English', code: 'en-US' },
],
},
]
// Handle updates
function handleVoiceIdUpdate(value: string) {
selectedVoiceId.value = value
}
function handleCustomValueUpdate(value: string) {
customValue.value = value
}
function handleSearchQueryUpdate(value: string) {
searchQuery.value = value
}
</script>
<template>
<Story
title="Voice Preview Player"
group="form"
:layout="{ type: 'grid', width: 800 }"
>
<template #controls>
<ThemeColorsHueControl />
</template>
<Variant id="default" title="Default">
<VoiceCardManySelect
:voices="voices"
:selected-voice-id="selectedVoiceId"
@update:selected-voice-id="handleVoiceIdUpdate"
@update:custom-value="handleCustomValueUpdate"
@update:search-query="handleSearchQueryUpdate"
/>
</Variant>
<Variant id="no-search" title="Without Search">
<VoiceCardManySelect
:voices="voices"
:selected-voice-id="selectedVoiceId"
:searchable="false"
@update:selected-voice-id="handleVoiceIdUpdate"
@update:custom-value="handleCustomValueUpdate"
/>
</Variant>
<Variant id="no-visualizer" title="Without Visualizer">
<VoiceCardManySelect
:voices="voices"
:selected-voice-id="selectedVoiceId"
:show-visualizer="false"
@update:selected-voice-id="handleVoiceIdUpdate"
@update:custom-value="handleCustomValueUpdate"
@update:search-query="handleSearchQueryUpdate"
/>
</Variant>
<Variant id="custom-text" title="Custom Text">
<VoiceCardManySelect
:voices="voices"
:selected-voice-id="selectedVoiceId"
search-placeholder="Find a voice..."
search-no-results-title="No matching voices"
search-no-results-description="Please try another search term"
search-results-text="Showing {count} out of {total} available voices"
custom-input-placeholder="Name this voice"
expand-button-text="Expand list"
collapse-button-text="Collapse list"
play-button-text="Listen"
pause-button-text="Stop"
@update:selected-voice-id="handleVoiceIdUpdate"
@update:custom-value="handleCustomValueUpdate"
@update:search-query="handleSearchQueryUpdate"
/>
</Variant>
<Variant id="empty" title="No Voices">
<VoiceCardManySelect
:voices="[]"
:selected-voice-id="selectedVoiceId"
@update:selected-voice-id="handleVoiceIdUpdate"
@update:custom-value="handleCustomValueUpdate"
@update:search-query="handleSearchQueryUpdate"
/>
</Variant>
<Variant id="many-voices" title="Many Voices">
<VoiceCardManySelect
:voices="[...voices, ...voices, ...voices]"
:selected-voice-id="selectedVoiceId"
@update:selected-voice-id="handleVoiceIdUpdate"
@update:custom-value="handleCustomValueUpdate"
@update:search-query="handleSearchQueryUpdate"
/>
</Variant>
</Story>
</template>
@@ -0,0 +1,436 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import VoiceCard from './VoiceCard.vue'
interface VoiceLanguage {
name: string
code: string
}
interface Voice {
id: string
name: string
description?: string
previewURL?: string
preview_audio_url?: string // Alternative field name
deprecated?: boolean
customizable?: boolean
labels?: {
accent?: string
age?: string
gender?: string
type?: string
}
languages?: VoiceLanguage[]
tags?: string[]
}
interface Props {
voices: Voice[]
selectedVoiceId: string
searchable?: boolean
searchPlaceholder?: string
searchNoResultsTitle?: string
searchNoResultsDescription?: string
searchResultsText?: string
customInputPlaceholder?: string
expandButtonText?: string
collapseButtonText?: string
playButtonText?: string
pauseButtonText?: string
showVisualizer?: boolean
}
const props = withDefaults(defineProps<Props>(), {
searchable: true,
searchPlaceholder: 'Search voices...',
searchNoResultsTitle: 'No voices found',
searchNoResultsDescription: 'Try a different search term',
searchResultsText: '{count} of {total} voices',
customInputPlaceholder: 'Enter custom voice name',
expandButtonText: 'Show more',
collapseButtonText: 'Show less',
playButtonText: 'Play sample',
pauseButtonText: 'Pause',
showVisualizer: true,
})
const emit = defineEmits<{
'update:selectedVoiceId': [value: string]
'update:searchQuery': [value: string]
}>()
const searchQuery = ref('')
const isListExpanded = ref(false)
const currentlyPlayingId = ref<string>()
const audioElements = ref<Map<string, HTMLAudioElement>>(new Map())
const audioStreams = ref<Map<string, MediaStream>>(new Map())
const audioContexts = ref<Map<string, AudioContext>>(new Map())
const audioSources = ref<Map<string, MediaElementAudioSourceNode>>(new Map())
// Add a single shared audio context
const sharedAudioContext = ref<AudioContext | null>(null)
// Initialize the shared audio context (call this in mounted or when needed)
function initAudioContext() {
if (!sharedAudioContext.value) {
sharedAudioContext.value = new (window.AudioContext || (window as any).webkitAudioContext)()
}
return sharedAudioContext.value
}
// Watch for search query changes and emit the value
watch(searchQuery, (value) => {
emit('update:searchQuery', value)
})
// Filter voices based on search query
const filteredVoices = computed(() => {
if (!searchQuery.value)
return props.voices
const query = searchQuery.value.toLowerCase()
return props.voices.filter((voice) => {
// Search in name and description
const nameMatch = voice.name.toLowerCase().includes(query)
const descMatch = voice.description && voice.description.toLowerCase().includes(query)
// Search in tags
const tagMatch = voice.tags && voice.tags.some(tag => tag.toLowerCase().includes(query))
// Search in labels
const labelMatch = voice.labels && Object.values(voice.labels).some(
value => typeof value === 'string' && value.toLowerCase().includes(query),
)
// Search in languages
const langMatch = voice.languages && voice.languages.some(
lang => lang.name.toLowerCase().includes(query) || lang.code.toLowerCase().includes(query),
)
return nameMatch || descMatch || tagMatch || labelMatch || langMatch
})
})
// Get preview URL from either field
function getPreviewUrl(voice: Voice): string | undefined {
return voice.previewURL || voice.preview_audio_url
}
// Create or get audio element for a voice
function getAudioElement(voice: Voice): HTMLAudioElement | null {
const previewUrl = getPreviewUrl(voice)
if (!previewUrl)
return null
if (audioElements.value.has(voice.id)) {
return audioElements.value.get(voice.id) || null
}
const audio = new Audio(previewUrl)
audio.crossOrigin = 'anonymous' // This is crucial for CORS
audio.preload = 'auto' // Preload the audio
audio.addEventListener('ended', () => {
if (currentlyPlayingId.value === voice.id) {
currentlyPlayingId.value = undefined
// Clean up the stream when audio ends
const stream = audioStreams.value.get(voice.id)
if (stream) {
stream.getTracks().forEach(track => track.stop())
audioStreams.value.delete(voice.id)
}
}
})
audioElements.value.set(voice.id, audio)
return audio
}
// Create audio stream for visualizer
function createAudioStream(audio: HTMLAudioElement, voiceId: string): MediaStream | null {
try {
// If we already have a stream for this voice, return it
if (audioStreams.value.has(voiceId)) {
return audioStreams.value.get(voiceId) || null
}
// Get the shared audio context
const audioContext = initAudioContext()
// Check if we already have a source for this audio element
if (audioSources.value.has(voiceId)) {
const source = audioSources.value.get(voiceId)!
const destination = audioContext.createMediaStreamDestination()
source.connect(destination)
const stream = destination.stream
audioStreams.value.set(voiceId, stream)
return stream
}
// Create a new source node
const source = audioContext.createMediaElementSource(audio)
audioSources.value.set(voiceId, source)
// Connect to the audio context destination (speakers)
source.connect(audioContext.destination)
// Create a stream destination for visualization
const destination = audioContext.createMediaStreamDestination()
source.connect(destination)
const stream = destination.stream
audioStreams.value.set(voiceId, stream)
return stream
}
catch (error) {
console.error('Failed to create audio stream for visualizer:', error)
return null
}
}
// Play or pause audio preview
function togglePlayback(voice: Voice) {
try {
const previewUrl = getPreviewUrl(voice)
if (!previewUrl)
return
const audio = getAudioElement(voice)
if (!audio)
return
// If this voice is currently playing, pause it
if (currentlyPlayingId.value === voice.id) {
audio.pause()
currentlyPlayingId.value = undefined
// Clean up the stream
const stream = audioStreams.value.get(voice.id)
if (stream) {
stream.getTracks().forEach(track => track.stop())
audioStreams.value.delete(voice.id)
}
return
}
// If another voice is playing, pause it first
if (currentlyPlayingId.value) {
const currentAudio = audioElements.value.get(currentlyPlayingId.value)
if (currentAudio) {
currentAudio.pause()
}
// Clean up the previous stream
const stream = audioStreams.value.get(currentlyPlayingId.value)
if (stream) {
stream.getTracks().forEach(track => track.stop())
audioStreams.value.delete(currentlyPlayingId.value)
}
}
// Create audio stream for visualizer if needed
if (props.showVisualizer && !audioStreams.value.has(voice.id)) {
createAudioStream(audio, voice.id)
}
// Play the selected voice
audio.currentTime = 0
audio.play().catch((error) => {
console.error('Failed to play audio:', error)
})
currentlyPlayingId.value = voice.id
}
catch (err) {
console.error(err)
currentlyPlayingId.value = undefined
}
}
function selectVoice(voiceId: string) {
emit('update:selectedVoiceId', voiceId)
}
// Clean up audio elements when component is unmounted
function cleanup() {
audioElements.value.forEach((audio) => {
audio.pause()
audio.src = ''
})
audioElements.value.clear()
// Clean up all streams
audioStreams.value.forEach((stream) => {
stream.getTracks().forEach(track => track.stop())
})
audioStreams.value.clear()
// Close all audio contexts
audioContexts.value.forEach((context) => {
if (context.state !== 'closed') {
context.close()
}
})
audioContexts.value.clear()
audioSources.value.clear()
currentlyPlayingId.value = undefined
}
// Stop all audio when search query changes
watch(searchQuery, () => {
if (currentlyPlayingId.value) {
const audio = audioElements.value.get(currentlyPlayingId.value)
if (audio) {
audio.pause()
}
// Clean up the stream
const stream = audioStreams.value.get(currentlyPlayingId.value)
if (stream) {
stream.getTracks().forEach(track => track.stop())
audioStreams.value.delete(currentlyPlayingId.value)
}
currentlyPlayingId.value = undefined
}
})
// Clean up when component is unmounted
onBeforeUnmount(cleanup)
// Add this to the script section
const customVoiceName = ref('')
</script>
<template>
<div class="voice-preview-player">
<!-- Search bar -->
<div v-if="searchable" class="relative" inline-flex="~" w-full items-center>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<div i-solar:magnifer-line-duotone class="text-neutral-500 dark:text-neutral-400" />
</div>
<input
v-model="searchQuery"
type="search"
class="w-full rounded-xl p-2.5 pl-10 text-sm outline-none"
border="focus:primary-100 dark:focus:primary-400/50 2 solid neutral-200 dark:neutral-800"
transition="all duration-200 ease-in-out"
bg="white dark:neutral-900"
:placeholder="searchPlaceholder"
>
</div>
<!-- Items list with search results info -->
<div class="mt-4 space-y-2">
<!-- Search results info -->
<div v-if="searchQuery" class="text-sm text-neutral-500 dark:text-neutral-400">
{{ searchResultsText.replace('{count}', filteredVoices.length.toString()).replace('{total}', voices.length.toString()) }}
</div>
<!-- No search results -->
<div
v-if="searchQuery && filteredVoices.length === 0"
class="flex items-center gap-3 border border-2 border-amber-200 rounded-xl bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20"
>
<div i-solar:info-circle-line-duotone class="text-2xl text-amber-500 dark:text-amber-400" />
<div class="flex flex-col">
<span class="font-medium">{{ searchNoResultsTitle }}</span>
<span class="text-sm text-amber-600 dark:text-amber-400">
{{ searchNoResultsDescription.replace('{query}', searchQuery) }}
</span>
</div>
</div>
<!-- Voices grid -->
<div class="relative">
<!-- Horizontally scrollable container -->
<div
class="scrollbar-hide grid auto-cols-[350px] grid-flow-col gap-4 overflow-x-auto pb-4"
:class="[
isListExpanded ? 'grid-cols-1 md:grid-cols-2 grid-flow-row auto-cols-auto' : '',
]"
transition="all duration-200 ease-in-out"
style="scroll-snap-type: x mandatory;"
>
<!-- Voice card with preview button -->
<VoiceCard
v-for="voice in filteredVoices"
:key="voice.id"
:voice="voice"
:selected-voice-id="selectedVoiceId"
:currently-playing-id="currentlyPlayingId"
:custom-input-placeholder="customInputPlaceholder"
:show-visualizer="showVisualizer"
:audio-stream="audioStreams.get(voice.id)"
@select="selectVoice"
@toggle-playback="togglePlayback"
@update:model-value="customVoiceName = $event"
/>
</div>
<!-- Expand/collapse handle -->
<div
bg="neutral-100 dark:[rgba(0,0,0,0.3)]"
rounded-xl
:class="[
isListExpanded ? 'fixed bottom-4 left-1/2 translate-x--1/2 z-10 w-full px-9 max-w-screen-lg' : 'mt-0 w-full rounded-lg',
]"
>
<button
w-full
flex items-center justify-center gap-2 rounded-lg py-2 transition="all duration-200 ease-in-out"
:class="[
isListExpanded ? 'bg-primary-500 hover:bg-primary-600 text-white' : 'bg-white dark:bg-neutral-900 hover:bg-neutral-100 dark:hover:bg-neutral-800',
]"
@click="isListExpanded = !isListExpanded"
>
<span>{{ isListExpanded ? collapseButtonText : expandButtonText }}</span>
<div
:class="isListExpanded ? 'rotate-180' : ''"
i-solar:alt-arrow-down-bold-duotone transition="transform duration-200 ease-in-out"
class="text-lg"
/>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
input[type='search']::-webkit-search-cancel-button {
display: none;
}
.voice-card {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.voice-card::before {
pointer-events: none;
--at-apply: 'bg-gradient-to-r from-primary-500/0 to-primary-500/0 dark:from-primary-400/0 dark:to-primary-400/0';
content: '';
position: absolute;
inset: 0;
z-index: 0;
width: 25%;
height: 100%;
transition: all 0.35s ease-in-out;
mask-image: linear-gradient(120deg, white 100%);
opacity: 0;
}
.voice-card:hover::before {
--at-apply: 'bg-gradient-to-r from-primary-500/20 via-primary-500/10 to-transparent dark:from-primary-400/20 dark:via-primary-400/10 dark:to-transparent';
width: 85%;
opacity: 1;
}
</style>
@@ -4,3 +4,5 @@ export * from './Input'
export * from './Radio'
export { default as RadioCardDetailManySelect } from './RadioCardDetailManySelect.vue'
export * from './Range'
export { default as VoiceCard } from './VoiceCard.vue'
export { default as VoiceCardManySelect } from './VoiceCardManySelect.vue'
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue'
import FieldRange from '../Form/Field/FieldRange.vue'
import Radio from '../Form/Radio/Radio.vue'
import AudioSpectrum from './AudioSpectrum.vue'
import AudioSpectrumVisualizer from './AudioSpectrumVisualizer.vue'
// Create a mock oscillator to generate audio for demonstration
const audioContext = ref<AudioContext>()
const oscillator = ref<OscillatorNode>()
const mediaStream = ref<MediaStream>()
const isPlaying = ref(false)
const frequency = ref(440) // A4 note
const waveform = ref<OscillatorType>('sine')
// Available waveforms for demonstration
const waveforms: OscillatorType[] = ['sine', 'square', 'sawtooth', 'triangle']
function createMockAudioStream() {
try {
// Create audio context
audioContext.value = new (window.AudioContext || (window as any).webkitAudioContext)()
// Create oscillator
oscillator.value = audioContext.value.createOscillator()
oscillator.value.type = waveform.value
oscillator.value.frequency.value = frequency.value
// Create media stream destination
const destination = audioContext.value.createMediaStreamDestination()
// Connect oscillator to destination
oscillator.value.connect(destination)
// Get the stream
mediaStream.value = destination.stream
// Start the oscillator
oscillator.value.start()
isPlaying.value = true
}
catch (error) {
console.error('Failed to create mock audio stream:', error)
}
}
function stopMockAudioStream() {
if (oscillator.value) {
oscillator.value.stop()
oscillator.value.disconnect()
oscillator.value = undefined
}
if (audioContext.value) {
audioContext.value.close()
audioContext.value = undefined
}
mediaStream.value = undefined
isPlaying.value = false
}
function toggleAudio() {
if (isPlaying.value) {
stopMockAudioStream()
}
else {
createMockAudioStream()
}
}
watch(frequency, (newFreq) => {
if (oscillator.value) {
oscillator.value.frequency.value = newFreq
}
})
watch(waveform, (newWaveform) => {
if (oscillator.value) {
oscillator.value.type = newWaveform
}
})
// Clean up on unmount
onBeforeUnmount(() => {
stopMockAudioStream()
})
</script>
<template>
<Story
title="Audio Spectrum"
group="gadgets"
:layout="{ type: 'grid', width: 600 }"
>
<template #controls>
<ThemeColorsHueControl />
</template>
<Variant
id="interactive-demo"
title="Interactive Demo"
>
<div class="rounded-xl bg-neutral-100 p-6 dark:bg-neutral-800">
<div class="mb-6 flex flex-col gap-4">
<div flex="~ row" items-center justify-between>
<div>
<h2 class="text-lg text-neutral-500 md:text-2xl dark:text-neutral-400">
Audio Spectrum
</h2>
<div text="neutral-400 dark:neutral-500">
<span>Playground of Audio Spectrum component</span>
</div>
</div>
<button
class="rounded-lg px-4 py-2 font-medium transition-colors"
:class="isPlaying
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-primary-500 hover:bg-primary-600 text-white'"
@click="toggleAudio"
>
{{ isPlaying ? 'Stop Audio' : 'Start Audio' }}
</button>
</div>
<!-- Controls -->
<div class="flex flex-col gap-4">
<FieldRange
v-model.number="frequency"
:min="50"
:max="2000"
:step="1"
label="Frequency"
/>
<div class="grid grid-cols-2 gap-2">
<Radio
v-for="(w, index) of waveforms"
id="waveform"
:key="index"
v-model="waveform"
name="waveform"
:value="w"
:title="w"
/>
</div>
</div>
</div>
<!-- Visualizer -->
<div class="h-40 overflow-hidden rounded-lg bg-neutral-200 p-4 dark:bg-neutral-900">
<div v-if="!mediaStream" class="h-full flex items-center justify-center text-neutral-500">
Click "Start Audio" to see the spectrum analyzer in action
</div>
<AudioSpectrum
v-else
v-slot="{ frequencies }"
:stream="mediaStream"
:bars="32"
:min-freq="20"
:max-freq="2000"
>
<AudioSpectrumVisualizer
:frequencies="frequencies"
bars-class="bg-primary-400 dark:bg-primary-500"
/>
</AudioSpectrum>
</div>
<div class="mt-4 text-sm text-neutral-500">
<p>This demo uses the Web Audio API to generate tones and visualize their frequency spectrum.</p>
<p class="mt-1">
Try changing the frequency and waveform to see how they affect the visualization.
</p>
</div>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
const props = withDefaults(defineProps<{
stream?: MediaStream
bars?: number
minFreq?: number // Minimum frequency in Hz
maxFreq?: number // Maximum frequency in Hz
}>(), {
bars: 32,
minFreq: 60, // Default human voice lower bound (~85Hz)
maxFreq: 4000, // Default human voice upper bound (~255Hz)
})
const frequencies = ref<number[]>(Array.from<number>({ length: props.bars }).fill(0))
onMounted(() => {
handleAnalyze()
})
watch(() => props.stream, () => {
handleAnalyze()
})
function handleAnalyze() {
if (!props.stream) {
return
}
const audioContext = new (window.AudioContext || (window as unknown as any).webkitAudioContext)()
const source = audioContext.createMediaStreamSource(props.stream)
const analyser = audioContext.createAnalyser()
analyser.fftSize = 2048
source.connect(analyser)
const bufferLength = analyser.frequencyBinCount
const dataArray = new Uint8Array(bufferLength)
// Calculate frequency resolution (Hz per bin)
const sampleRate = audioContext.sampleRate
const frequencyResolution = sampleRate / analyser.fftSize
// Calculate bin indices for min and max frequencies
const minBin = Math.floor(props.minFreq / frequencyResolution)
const maxBin = Math.floor(props.maxFreq / frequencyResolution)
const usableBins = maxBin - minBin
// Calculate bins per bar based on the filtered frequency range
const binsPerBar = Math.floor(usableBins / props.bars)
const analyze = () => {
try {
requestAnimationFrame(analyze)
analyser.getByteFrequencyData(dataArray)
const bars = Array.from<number>({ length: props.bars }).fill(0)
for (let i = 0; i < props.bars; i++) {
let sum = 0
const startBin = minBin + (i * binsPerBar)
for (let j = 0; j < binsPerBar; j++) {
const binIndex = startBin + j
if (binIndex < maxBin) // Ensure we don't exceed max frequency
sum += dataArray[binIndex]
}
bars[i] = sum / binsPerBar / 255 // Normalize to 0-1
}
frequencies.value = bars
}
catch (err) {
console.error(err)
}
}
analyze()
}
</script>
<template>
<slot :frequencies="frequencies" />
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
const props = defineProps<{
frequencies: number[]
barsClass?: string
}>()
const AMPLIFICATION = 5
function getReductionFactor(index: number, totalBars: number) {
const minFactor = 0.1 // More reduction for bass frequencies
const maxFactor = 1.0 // Less reduction for higher frequencies
return minFactor + (maxFactor - minFactor) * (index / totalBars)
}
function getBarHeight(frequency: number, index: number) {
const reductionFactor = getReductionFactor(index, props.frequencies.length)
return Math.min(100, Math.max(10, frequency * 100 * AMPLIFICATION * reductionFactor))
}
</script>
<template>
<div h-full flex items-center gap-1>
<div v-for="(frequency, index) in frequencies" :key="index" h-full flex flex-1 items-end>
<div
transition="all 100 ease-in-out" mx-auto my-0 w-full rounded-full :class="barsClass"
:style="{ height: `${getBarHeight(frequency, index)}%` }"
/>
</div>
</div>
</template>
@@ -80,7 +80,7 @@ import TestDummyMarkerFlat from './TestDummyMarkerFlat.vue'
title="Original Implementation"
>
<div class="h-40 flex items-center justify-center">
<TestDummyMarker :size="40" />
<TestDummyMarker />
</div>
</Variant>
</Story>
@@ -1 +1,3 @@
export { default as AudioSpectrum } from './AudioSpectrum.vue'
export { default as AudioSpectrumVisualizer } from './AudioSpectrumVisualizer.vue'
export { default as TestDummyMarker } from './TestDummyMarker.vue'
+20 -1
View File
@@ -78,6 +78,7 @@ export interface VoiceInfo {
gender?: string
language?: string
deprecated?: boolean
previewURL?: string
}
export const useProvidersStore = defineStore('providers', () => {
@@ -326,11 +327,29 @@ export const useProvidersStore = defineStore('providers', () => {
...provider.voice(),
})
return voices.map((voice) => {
// Find indices of Aria and Bill
const ariaIndex = voices.findIndex(voice => voice.name.includes('Aria'))
const billIndex = voices.findIndex(voice => voice.name.includes('Bill'))
// Determine the range to move (ensure valid indices and proper order)
const startIndex = ariaIndex !== -1 ? ariaIndex : 0
const endIndex = billIndex !== -1 ? billIndex : voices.length - 1
const lowerIndex = Math.min(startIndex, endIndex)
const higherIndex = Math.max(startIndex, endIndex)
// Rearrange voices: voices outside the range first, then voices within the range
const rearrangedVoices = [
...voices.slice(0, lowerIndex),
...voices.slice(higherIndex + 1),
...voices.slice(lowerIndex, higherIndex + 1),
]
return rearrangedVoices.map((voice) => {
return {
id: voice.id,
name: voice.name,
provider: 'elevenlabs',
previewURL: voice.preview_audio_url,
}
})
},