feat(stage-ui): voice card, audio spectrum
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user