fix(stage-web|stage-tamagotchi): speech

This commit is contained in:
Neko Ayaka
2025-03-20 00:28:22 +08:00
parent 6f76310b8c
commit 4579ca24eb
11 changed files with 275 additions and 160 deletions
@@ -47,18 +47,22 @@ settings:
chinese: 简体中文
english: English
title: Language
live2d:
change-model:
from-file: Load from File
from-file-select: Select
from-url: Load from URL
from-url-confirm: Load
from-url-placeholder: Enter Live2D model URL
title: Change Model
edit-motion-map:
title: Edit motion map
map-motions:
play: Play Motion
title: Map Motions
title: Live2D Settings
microphone: Microphone
models: Model
quit: Quit
viewer: Viewer
shortcuts:
title: Shortcuts
window:
move: Move the window
resize: Resize the window
debug: Toggle developer tools
press_keys: Press keys...
other: Other
pages:
models:
description: Live2D, VRM, etc.
@@ -69,10 +73,9 @@ settings:
sections:
section:
provider-model-selection:
title: Model
description: Select the suitable LLM provider for consciousness
collapse: Collapse
custom_model_placeholder: Enter custom model name...
description: Select the suitable LLM provider for consciousness
error: Error loading models
expand: Expand
loading: Loading available models...
@@ -89,6 +92,7 @@ settings:
show_less: Show less
show_more: Show more
subtitle: Select a model from the provider
title: Model
title: Consciousness
description: Thinking, vision, speech synthesis, gaming, etc.
gaming-factorio:
@@ -111,13 +115,49 @@ settings:
title: Discord
speech:
description: Speech synthesis
sections:
section:
playground:
buttons:
stop:
label: Stop
select-voice:
required: Please select a voice
provider-voice-selection:
custom_model_placeholder: Enter custom model name...
custom_voice_placeholder: Enter custom voice ID...
description: Select the suitable speech provider
no_models: No models available
no_models_description: No models were found for this provider
no_voices: No voices available
no_voices_description: No voices were found for this provider
search_models_placeholder: Search models...
search_models_results: Found {count} of {total} models
search_voices_placeholder: Search voices...
search_voices_results: Found {count} of {total} voices
show_less: Show less
show_more: Show more
title: Provider
voice-settings:
input-ssml:
placeholder: Enter SSML text...
use-ssml:
description: Enable to input raw SSML instead of plain text
label: Use Custom SSML
title: Speech
title: Modules
vision:
description: Vision
title: Vision
x:
description: X / Twitter browsing and usage
title: X / Twitter
providers:
common:
fields:
field:
api-key:
label: API Key Input
section:
advanced:
fields:
@@ -148,6 +188,20 @@ settings:
api-key:
placeholder: Input Cloudflare Account ID
title: Cloudflare Workers AI
common:
fields:
field:
pitch:
description: >-
Tune the pitch of synthesized speech (e.g., sharper or
coarser)
label: Pitch
speed:
description: Adjust the speed of speech
label: Speed
volume:
description: Adjust the volume of speech
label: Volume
deepseek:
description: deepseek.com
title: DeepSeek
@@ -193,6 +247,12 @@ settings:
fireworks:
description: fireworks.ai
title: Fireworks.ai
microsoft-speech:
fields:
field:
region:
description: Speech Service region
label: Region
mistral:
description: mistral.ai
title: Mistral
@@ -246,10 +306,10 @@ settings:
description: Traditional Japanese color palette
title: Japanese Colors
monet:
description: Impressionist palette inspired by Claude Monet\'s works
description: Impressionist palette inspired by Claude Monet's works
title: Monet Colors
morandi:
description: Soft, muted tones inspired by Giorgio Morandi\'s paintings
description: Soft, muted tones inspired by Giorgio Morandi's paintings
title: Morandi Colors
nordic:
description: Scandinavian minimalist color scheme
@@ -35,17 +35,20 @@ settings:
chinese: 简体中文
english: English
title: 语言
live2d:
change-model:
from-file: 从文件加载
from-file-select: 选择
from-url: 从 URL 加载
from-url-confirm: 加载
from-url-placeholder: 输入 Live2D 模型 URL
title: 更换模型
map-motions:
play: 播放动作
title: 映射动作
title: Live2D 设置
microphone: 麦克风
models: 模型
quit: 退出
viewer: 查看器
shortcuts:
title: 快捷键
window:
move: 移动窗口
resize: 调整窗口大小
debug: 切换开发者模式
press_keys: 按下快捷键...
other: 其他
pages:
models:
description: 切换角色的 Live2DVRM 模型
@@ -53,14 +56,12 @@ settings:
modules:
consciousness:
description: 人格设定、使用的模型等配置
title: 意识
sections:
section:
provider-model-selection:
title: 模型
description: 为意识选择合适的 LLM 提供商
collapse: 折叠
custom_model_placeholder: 输入自订模型名字
description: 为意识选择合适的 LLM 提供商
error: 获取出错啦
expand: 展开
loading: 获取模型中...
@@ -77,6 +78,8 @@ settings:
show_less: 显示更多
show_more: 收起
subtitle: 选择一个模型
title: 模型
title: 意识
description: 思维,视觉,言语综合,游戏等
gaming-factorio:
description: 一起玩 Factorio
@@ -98,19 +101,57 @@ settings:
title: Discord
speech:
description: 语音合成
sections:
section:
playground:
buttons:
stop:
label: 停止
select-voice:
required: 请选择声线
provider-voice-selection:
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: 选择语音合成提供商
voice-settings:
input-ssml:
placeholder: 请输入 SSML 文本
use-ssml:
description: 启用后可以输入原始 SSML 而不是纯文本
label: 选用自定义 SSML
title: 发声
title: 机体模块
vision:
description: 视觉
title: 视觉
x:
description: X / Twitter 的浏览和使用
title: X / Twitter
providers:
common:
fields:
field:
api-key:
label: API 密钥
section:
advanced:
fields:
field:
headers:
description: 添加自定义HTTP标头
description: 添加自定义 HTTP 标头
key:
placeholder: 请求头名字
label: HTTP 请求头
@@ -129,12 +170,24 @@ settings:
fields:
field:
account-id:
description: Cloudflare帐户ID
description: Cloudflare 帐户 ID
label: Cloudflare Account ID
placeholder: 请输入 Cloudflare Account ID
api-key:
placeholder: 请输入 Cloudflare Workers AI 的 API Key
title: Workers AI
common:
fields:
field:
pitch:
description: 调节合成语音的音高(比如更尖锐或更粗旷)
label: 音高
speed:
description: 调节说话的语速
label: 语速
volume:
description: 调节语音的响度
label: 响度
deepseek:
description: DeepSeek.com
title: 深度求索 DeepSeek
@@ -180,6 +233,12 @@ settings:
fireworks:
description: Fireworks.ai
title: Fireworks.ai
microsoft-speech:
fields:
field:
region:
description: 服务 Endpoint 地区(比如亚太 eastasia 区域)
label: Endpoint 地区
mistral:
description: mistral.ai
title: Mistral
@@ -231,7 +290,7 @@ settings:
monet:
title: 莫奈颜色
morandi:
title: Morandi颜色
title: Morandi 颜色
nordic:
title: 北欧颜色
title: 主题预设
@@ -29,6 +29,7 @@ const {
activeSpeechProvider,
activeSpeechModel,
activeSpeechVoice,
activeSpeechVoiceId,
pitch,
isLoadingSpeechProviderVoices,
supportsModelListing,
@@ -43,7 +44,6 @@ const {
const router = useRouter()
const voiceId = ref('')
const voiceSearchQuery = ref('')
const useSSML = ref(false)
const testText = ref('Hello, my name is AI Assistant')
@@ -100,7 +100,7 @@ async function generateTestSpeech() {
const input = useSSML.value
? ssmlText.value
: speechStore.generateSSML(testText.value, activeSpeechVoice.value)
: speechStore.generateSSML(testText.value, activeSpeechVoice.value, { ...providerConfig, pitch: pitch.value })
const response = await generateSpeech({
...provider.speech(activeSpeechModel.value, providerConfig),
@@ -148,10 +148,6 @@ onUnmounted(() => {
}
})
function handleVoiceSelection(value: string) {
activeSpeechVoice.value = availableVoices.value[activeSpeechProvider.value].find(voice => voice.id === value)
}
function updateCustomVoiceName(value: string) {
activeSpeechVoice.value = {
id: value,
@@ -167,16 +163,6 @@ function updateCustomVoiceName(value: string) {
function updateCustomModelName(value: string) {
activeSpeechModel.value = value
}
watch(voiceId, (newVoice) => {
const foundVoice = availableVoices.value[activeSpeechProvider.value].find(voice => voice.id === newVoice)
if (foundVoice) {
activeSpeechVoice.value = foundVoice
}
else {
updateCustomVoiceName(newVoice)
}
})
</script>
<template>
@@ -353,6 +339,7 @@ watch(voiceId, (newVoice) => {
>
<VoiceCardManySelect
v-model:search-query="voiceSearchQuery"
v-model:voice-id="activeSpeechVoiceId"
:voices="availableVoices[activeSpeechProvider]?.map(voice => ({
id: voice.id,
name: voice.name,
@@ -360,7 +347,6 @@ watch(voiceId, (newVoice) => {
previewURL: voice.previewURL,
customizable: false,
}))"
:selected-voice-id="activeSpeechVoice?.id"
: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')"
@@ -371,7 +357,6 @@ watch(voiceId, (newVoice) => {
: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"
/>
</div>
@@ -423,7 +408,7 @@ watch(voiceId, (newVoice) => {
class="mt-2 space-y-6"
>
<FieldInput
v-model="voiceId"
v-model="activeSpeechVoiceId"
type="text"
label="Voice ID"
description="Enter the voice ID for your custom voice"
@@ -10,7 +10,7 @@ import {
} from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -29,6 +29,10 @@ const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const pitch = ref(0)
const speed = ref(1.0)
const volume = ref(0)
// Additional settings specific to Microsoft Speech (region)
const region = computed({
get: () => providers.value[providerId]?.region as string | undefined || 'eastasia',
@@ -80,6 +84,7 @@ async function handleGenerateSpeech(input: string, voiceId: string, useSSML: boo
const ssml = speechStore.generateSSML(
input,
voice,
{ ...providerConfig, pitch: pitch.value },
)
return await speechStore.speech(
provider,
@@ -123,6 +128,9 @@ async function handleGenerateSpeech(input: string, voiceId: string, useSSML: boo
<!-- Voice settings specific to Microsoft Speech -->
<template #voice-settings="{ voiceSettings, updateVoiceSettings }">
<SpeechVoiceSettings
v-model:pitch="pitch"
v-model:speed="speed"
v-model:volume="volume"
:settings="voiceSettings"
:show-pitch="true"
:show-speed="true"
@@ -29,6 +29,7 @@ const {
activeSpeechProvider,
activeSpeechModel,
activeSpeechVoice,
activeSpeechVoiceId,
pitch,
isLoadingSpeechProviderVoices,
supportsModelListing,
@@ -43,7 +44,6 @@ const {
const router = useRouter()
const voiceId = ref('')
const voiceSearchQuery = ref('')
const useSSML = ref(false)
const testText = ref('Hello, my name is AI Assistant')
@@ -100,7 +100,7 @@ async function generateTestSpeech() {
const input = useSSML.value
? ssmlText.value
: speechStore.generateSSML(testText.value, activeSpeechVoice.value)
: speechStore.generateSSML(testText.value, activeSpeechVoice.value, { ...providerConfig, pitch: pitch.value })
const response = await generateSpeech({
...provider.speech(activeSpeechModel.value, providerConfig),
@@ -148,10 +148,6 @@ onUnmounted(() => {
}
})
function handleVoiceSelection(value: string) {
activeSpeechVoice.value = availableVoices.value[activeSpeechProvider.value].find(voice => voice.id === value)
}
function updateCustomVoiceName(value: string) {
activeSpeechVoice.value = {
id: value,
@@ -167,16 +163,6 @@ function updateCustomVoiceName(value: string) {
function updateCustomModelName(value: string) {
activeSpeechModel.value = value
}
watch(voiceId, (newVoice) => {
const foundVoice = availableVoices.value[activeSpeechProvider.value].find(voice => voice.id === newVoice)
if (foundVoice) {
activeSpeechVoice.value = foundVoice
}
else {
updateCustomVoiceName(newVoice)
}
})
</script>
<template>
@@ -353,6 +339,7 @@ watch(voiceId, (newVoice) => {
>
<VoiceCardManySelect
v-model:search-query="voiceSearchQuery"
v-model:voice-id="activeSpeechVoiceId"
:voices="availableVoices[activeSpeechProvider]?.map(voice => ({
id: voice.id,
name: voice.name,
@@ -360,7 +347,6 @@ watch(voiceId, (newVoice) => {
previewURL: voice.previewURL,
customizable: false,
}))"
:selected-voice-id="activeSpeechVoice?.id"
: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')"
@@ -371,7 +357,6 @@ watch(voiceId, (newVoice) => {
: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"
/>
</div>
@@ -423,7 +408,7 @@ watch(voiceId, (newVoice) => {
class="mt-2 space-y-6"
>
<FieldInput
v-model="voiceId"
v-model="activeSpeechVoiceId"
type="text"
label="Voice ID"
description="Enter the voice ID for your custom voice"
@@ -10,7 +10,7 @@ import {
} from '@proj-airi/stage-ui/components'
import { useProvidersStore, useSpeechStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -29,6 +29,10 @@ const speechStore = useSpeechStore()
const providersStore = useProvidersStore()
const { providers } = storeToRefs(providersStore)
const pitch = ref(0)
const speed = ref(1.0)
const volume = ref(0)
// Additional settings specific to Microsoft Speech (region)
const region = computed({
get: () => providers.value[providerId]?.region as string | undefined || 'eastasia',
@@ -80,6 +84,7 @@ async function handleGenerateSpeech(input: string, voiceId: string, useSSML: boo
const ssml = speechStore.generateSSML(
input,
voice,
{ ...providerConfig, pitch: pitch.value },
)
return await speechStore.speech(
provider,
@@ -123,6 +128,9 @@ async function handleGenerateSpeech(input: string, voiceId: string, useSSML: boo
<!-- Voice settings specific to Microsoft Speech -->
<template #voice-settings="{ voiceSettings, updateVoiceSettings }">
<SpeechVoiceSettings
v-model:pitch="pitch"
v-model:speed="speed"
v-model:volume="volume"
:settings="voiceSettings"
:show-pitch="true"
:show-speed="true"
@@ -1,6 +1,4 @@
<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'
@@ -30,8 +28,8 @@ interface Voice {
}
interface Props {
name: string
voice: Voice
selectedVoiceId?: string
currentlyPlayingId?: string
customInputPlaceholder?: string
showVisualizer?: boolean
@@ -45,15 +43,11 @@ const props = withDefaults(defineProps<Props>(), {
})
const emit = defineEmits<{
'select': [voiceId: string]
'togglePlayback': [voice: Voice]
'update:modelValue': [value: string]
togglePlayback: [voice: Voice]
}>()
const modelValue = computed({
get: () => '',
set: value => emit('update:modelValue', value),
})
const voiceId = defineModel<string>('voice-id', { required: false, default: '' })
const customVoiceName = defineModel<string>('custom-voice-name', { required: false, default: '' })
// Get preview URL from either field
function getPreviewUrl(voice: Voice): string | undefined {
@@ -88,36 +82,42 @@ function formatVoiceAttributes(voice: Voice): string[] {
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"
<label
border="2px solid"
class="scroll-snap-align-start form_voice-card relative flex flex-col overflow-hidden rounded-xl"
transition="all duration-200 ease-in-out"
border="2 solid"
:class="[
selectedVoiceId === voice.id
voiceId === props.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' : '',
voiceId === props.voice.id
? 'form_voice-card-active'
: '',
]"
>
<!-- 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">
<input
v-model="voiceId"
:checked="voiceId === props.voice.id"
type="radio"
:name="name"
:value="props.voice.id"
class="absolute cursor-pointer opacity-0"
>
<div class="relative mr-3 mt-0.5 flex-shrink-0">
<div
class="size-5 cursor-pointer border-2 rounded-full transition-colors duration-200"
class="size-5 border-2 rounded-full transition-colors duration-200"
:class="[
selectedVoiceId === voice.id
voiceId === props.voice.id
? 'border-primary-500 dark:border-primary-400'
: 'border-neutral-300 dark:border-neutral-600',
]"
@@ -125,7 +125,7 @@ function togglePlayback() {
<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
voiceId === props.voice.id
? 'opacity-100 bg-primary-500 dark:bg-primary-400'
: 'opacity-0',
]"
@@ -156,12 +156,12 @@ function togglePlayback() {
</div>
<!-- Voice info -->
<div class="flex-1 cursor-pointer" @click="selectVoice">
<div class="flex-1 cursor-pointer">
<div class="flex items-center">
<span
class="line-clamp-1 font-medium"
:class="[
selectedVoiceId === voice.id
voiceId === voice.id
? 'text-neutral-700 dark:text-neutral-300'
: 'text-neutral-700 dark:text-neutral-400',
]"
@@ -177,7 +177,7 @@ function togglePlayback() {
:key="attrIndex"
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-xs"
:class="[
selectedVoiceId === voice.id
voiceId === 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',
]"
@@ -187,9 +187,9 @@ function togglePlayback() {
</div>
<!-- Custom input field for selected voice -->
<div v-if="voice.customizable && selectedVoiceId === voice.id" class="mt-3">
<div v-if="voice.customizable && voiceId === voice.id" class="mt-3">
<Input
v-model="modelValue"
v-model="customVoiceName"
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"
@@ -200,7 +200,7 @@ function togglePlayback() {
<div
v-if="voice.description" class="line-clamp-2 mt-1 text-xs"
:class="[
selectedVoiceId === voice.id
voiceId === voice.id
? 'text-neutral-600 dark:text-neutral-400'
: 'text-neutral-500 dark:text-neutral-500',
]"
@@ -227,7 +227,7 @@ function togglePlayback() {
>
<AudioSpectrumVisualizer
:frequencies="frequencies"
:bars-class="selectedVoiceId === voice.id
:bars-class="voiceId === voice.id
? 'bg-primary-500 dark:bg-primary-400'
: 'bg-neutral-400 dark:bg-neutral-600'"
/>
@@ -235,18 +235,18 @@ function togglePlayback() {
</div>
</TransitionVertical>
</div>
</div>
</label>
</template>
<style scoped>
.voice-card {
.form_voice-card {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.voice-card::before {
.form_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: '';
@@ -260,7 +260,7 @@ function togglePlayback() {
opacity: 0;
}
.voice-card:hover::before {
.form_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;
@@ -28,7 +28,6 @@ interface Voice {
interface Props {
voices: Voice[]
selectedVoiceId?: string
searchable?: boolean
searchPlaceholder?: string
searchNoResultsTitle?: string
@@ -56,12 +55,6 @@ const props = withDefaults(defineProps<Props>(), {
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())
@@ -80,10 +73,8 @@ function initAudioContext() {
return sharedAudioContext.value
}
// Watch for search query changes and emit the value
watch(searchQuery, (value) => {
emit('update:searchQuery', value)
})
const searchQuery = defineModel<string>('search-query', { required: false, default: '' })
const voiceId = defineModel<string>('voice-id', { required: false, default: '' })
// Filter voices based on search query
const filteredVoices = computed(() => {
@@ -251,10 +242,6 @@ function togglePlayback(voice: Voice) {
}
}
function selectVoice(voiceId: string) {
emit('update:selectedVoiceId', voiceId)
}
// Clean up audio elements when component is unmounted
function cleanup() {
audioElements.value.forEach((audio) => {
@@ -350,7 +337,7 @@ const customVoiceName = ref('')
<div class="relative">
<!-- Horizontally scrollable container -->
<div
class="scrollbar-hide grid auto-cols-[350px] grid-flow-col max-h-[calc(100dvh-7lh)] gap-4 overflow-x-auto pb-4"
class="scrollbar-hide grid auto-cols-[350px] grid-flow-col max-h-[calc(100dvh-7lh)] gap-4 overflow-x-auto"
:class="[
isListExpanded ? 'grid-cols-1 md:grid-cols-2 grid-flow-row auto-cols-auto' : '',
]"
@@ -361,15 +348,15 @@ const customVoiceName = ref('')
<VoiceCard
v-for="voice in filteredVoices"
:key="voice.id"
v-model:voice-id="voiceId"
v-model:custom-voice-name="customVoiceName"
name="voice"
: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>
@@ -15,16 +15,15 @@ defineProps<{
showSpeakerBoost?: boolean
}>()
const emit = defineEmits<{
update: [key: string, value: any]
}>()
const pitch = defineModel<number>('pitch', { required: false, default: 0 })
const speed = defineModel<number>('speed', { required: false, default: 1.0 })
const volume = defineModel<number>('volume', { required: false, default: 0 })
const style = defineModel<number>('style', { required: false, default: 0 })
const stability = defineModel<number>('stability', { required: false, default: 0.5 })
const similarityBoost = defineModel<number>('similarityBoost', { required: false, default: 0.75 })
const useSpeakerBoost = defineModel<boolean>('useSpeakerBoost', { required: false, default: false })
const { t } = useI18n()
// Define a function to update settings
function updateSetting(key: string, value: any) {
emit('update', key, value)
}
</script>
<template>
@@ -32,72 +31,69 @@ function updateSetting(key: string, value: any) {
<!-- Pitch control - common to most providers -->
<FieldRange
v-if="showPitch"
:model-value="settings.pitch ?? 0"
v-model="pitch"
:label="t('settings.pages.providers.provider.common.fields.field.pitch.label')"
:description="t('settings.pages.providers.provider.common.fields.field.pitch.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
@update:model-value="value => updateSetting('pitch', value)"
/>
<!-- Speed control - common to most providers -->
<FieldRange
v-if="showSpeed"
:model-value="settings.speed ?? 1.0"
v-model="speed"
:label="t('settings.pages.providers.provider.common.fields.field.speed.label')"
:description="t('settings.pages.providers.provider.common.fields.field.speed.description')"
:min="0.5"
:max="2.0" :step="0.01" @update:model-value="value => updateSetting('speed', value)"
:max="2.0" :step="0.01"
/>
<!-- Volume control - available in some providers -->
<FieldRange
v-if="showVolume"
:model-value="settings.volume ?? 0"
v-model="volume"
:label="t('settings.pages.providers.provider.common.fields.field.volume.label')"
:description="t('settings.pages.providers.provider.common.fields.field.volume.description')"
:min="-100"
:max="100" :step="1" :format-value="value => `${value}%`"
@update:model-value="value => updateSetting('volume', value)"
/>
<!-- Style control - specific to ElevenLabs -->
<FieldRange
v-if="showStyle"
:model-value="settings.style ?? 0"
v-model="style"
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.style.label')"
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.style.description')"
:min="0"
:max="1" :step="0.01" @update:model-value="value => updateSetting('style', value)"
:max="1" :step="0.01"
/>
<!-- Stability control - specific to ElevenLabs -->
<FieldRange
v-if="showStability"
:model-value="settings.stability ?? 0.5"
v-model="stability"
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.stability.label')"
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.stability.description')"
:min="0"
:max="1" :step="0.01" @update:model-value="value => updateSetting('stability', value)"
:max="1" :step="0.01"
/>
<!-- Similarity Boost control - specific to ElevenLabs -->
<FieldRange
v-if="showSimilarityBoost"
:model-value="settings.similarityBoost ?? 0.75"
v-model="similarityBoost"
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.simularity-boost.label')"
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.simularity-boost.description')"
:min="0"
:max="1" :step="0.01" @update:model-value="value => updateSetting('similarityBoost', value)"
:max="1" :step="0.01"
/>
<!-- Speaker Boost checkbox - specific to ElevenLabs -->
<FieldCheckbox
v-if="showSpeakerBoost"
:model-value="settings.useSpeakerBoost !== false"
v-model="useSpeakerBoost"
:label="t('settings.pages.providers.provider.elevenlabs.fields.field.speaker-boost.label')"
:description="t('settings.pages.providers.provider.elevenlabs.fields.field.speaker-boost.description')"
@update:model-value="value => updateSetting('useSpeakerBoost', value)"
/>
<!-- Slot for additional provider-specific controls -->
@@ -72,7 +72,7 @@ const audioQueue = useQueue<{ audioBuffer: AudioBuffer, text: string }>({
})
const speechStore = useSpeechStore()
const { ssmlEnabled, activeSpeechProvider, activeSpeechModel, activeSpeechVoice } = storeToRefs(speechStore)
const { ssmlEnabled, activeSpeechProvider, activeSpeechModel, activeSpeechVoice, pitch } = storeToRefs(speechStore)
async function handleSpeechGeneration(ctx: { data: string }) {
try {
@@ -96,7 +96,7 @@ async function handleSpeechGeneration(ctx: { data: string }) {
const providerConfig = providersStore.getProviderConfig(activeSpeechProvider.value)
const input = ssmlEnabled.value
? speechStore.generateSSML(ctx.data, activeSpeechVoice.value)
? speechStore.generateSSML(ctx.data, activeSpeechVoice.value, { ...providerConfig, pitch: pitch.value })
: ctx.data
const res = await generateSpeech({
+40 -13
View File
@@ -134,15 +134,27 @@ export const useSpeechStore = defineStore('speech', () => {
})
onMounted(() => {
if (activeSpeechVoiceId.value) {
activeSpeechVoice.value = availableVoices.value[activeSpeechProvider.value]?.find(voice => voice.id === activeSpeechVoiceId.value)
}
loadVoicesForProvider(activeSpeechProvider.value).then(() => {
if (activeSpeechVoiceId.value) {
activeSpeechVoice.value = availableVoices.value[activeSpeechProvider.value]?.find(voice => voice.id === activeSpeechVoiceId.value)
}
})
})
watch(activeSpeechVoice, (voice) => {
if (voice) {
activeSpeechVoiceId.value = voice.id
watch(activeSpeechVoiceId, (voiceId) => {
if (voiceId) {
activeSpeechVoice.value = availableVoices.value[activeSpeechProvider.value]?.find(voice => voice.id === voiceId)
}
}, {
immediate: true,
})
watch(availableVoices, (voices) => {
if (activeSpeechVoiceId.value) {
activeSpeechVoice.value = voices[activeSpeechProvider.value]?.find(voice => voice.id === activeSpeechVoiceId.value)
}
}, {
immediate: true,
})
/**
@@ -176,19 +188,33 @@ export const useSpeechStore = defineStore('speech', () => {
function generateSSML(
text: string,
voice: VoiceInfo,
pitch?: number,
speed?: number,
volume?: number,
providerConfig?: Record<string, any>,
): string {
const pitch = providerConfig?.pitch
const speed = providerConfig?.speed
const volume = providerConfig?.volume
const prosody = {
pitch: pitch != null ? pitch > 0 ? `+${pitch}%` : `-${pitch}%` : undefined,
rate: speed != null ? speed !== 1.0 ? `${speed}` : '1' : undefined,
volume: volume != null ? volume > 0 ? `+${volume}%` : `${volume}%` : undefined,
pitch: pitch != null
? pitch > 0
? `+${pitch}%`
: `-${pitch}%`
: undefined,
rate: speed != null
? speed !== 1.0
? `${speed}`
: '1'
: undefined,
volume: volume != null
? volume > 0
? `+${volume}%`
: `${volume}%`
: undefined,
}
const ssmlXast = x('speak', { 'version': '1.0', 'xmlns': 'http://www.w3.org/2001/10/synthesis', 'xml:lang': voice.languages[0]?.code || 'en-US' }, [
x('voice', { name: voice.id, gender: voice.gender || 'neutral' }, [
Object.entries(prosody).filter(([_, value]) => value !== undefined).length > 0
Object.entries(prosody).filter(([_, value]) => value != null).length > 0
? x('prosody', {
pitch: pitch != null ? pitch > 0 ? `+${pitch}%` : `-${pitch}%` : undefined,
rate: speed != null ? speed !== 1.0 ? `${speed}` : '1' : undefined,
@@ -208,6 +234,7 @@ export const useSpeechStore = defineStore('speech', () => {
activeSpeechProvider,
activeSpeechModel,
activeSpeechVoice,
activeSpeechVoiceId,
pitch,
rate,
ssmlEnabled,