feat(stage-ui): add Kokoro TTS (Local) speech provider (#1005)
This commit is contained in:
@@ -45,7 +45,16 @@ runs:
|
||||
run: |
|
||||
if [[ "${{ inputs.version }}" == "latest" ]]; then
|
||||
# Fetch the latest release tag using a more robust method
|
||||
API_RESPONSE=$(curl -s https://api.github.com/repos/realm/SwiftLint/releases/latest)
|
||||
CURL_ARGS=(-s)
|
||||
if [[ -n "${{ github.token }}" ]]; then
|
||||
CURL_ARGS+=(-H "Authorization: Bearer ${{ github.token }}")
|
||||
fi
|
||||
CURL_ARGS+=(-H "Accept: application/vnd.github+json")
|
||||
CURL_ARGS+=(-H "X-GitHub-Api-Version: 2022-11-28")
|
||||
CURL_ARGS+=("https://api.github.com/repos/realm/SwiftLint/releases/latest")
|
||||
|
||||
API_RESPONSE=$(curl "${CURL_ARGS[@]}")
|
||||
|
||||
# Try using jq if available (most GitHub Actions runners have it)
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
VERSION=$(echo "$API_RESPONSE" | jq -r '.tag_name // empty')
|
||||
|
||||
@@ -368,6 +368,7 @@ pages:
|
||||
stop:
|
||||
label: Stop
|
||||
select-voice:
|
||||
loading: Loading model...
|
||||
required: Please select a voice
|
||||
provider-voice-selection:
|
||||
custom_model_placeholder: Enter custom model name...
|
||||
@@ -632,6 +633,30 @@ pages:
|
||||
validation:
|
||||
error-missing-api-key: Please enter an API key to test the voice.
|
||||
title: ElevenLabs
|
||||
kokoro-local:
|
||||
description: Local text-to-speech using Kokoro-82M.
|
||||
fields:
|
||||
field:
|
||||
model:
|
||||
label: Model Selection
|
||||
description: Smaller models load faster but may have slightly lower quality.
|
||||
models:
|
||||
fp32-webgpu:
|
||||
description: Full precision model using WebGPU - Recommended for supported devices
|
||||
fp32:
|
||||
description: Full precision model
|
||||
fp16:
|
||||
description: Half precision
|
||||
q8:
|
||||
description: 8-bit quantized
|
||||
q4:
|
||||
description: 4-bit quantized
|
||||
q4f16:
|
||||
description: 4-bit with FP16
|
||||
playground:
|
||||
default-text: Hello! This is a test of the Kokoro text-to-speech system.
|
||||
title: Voice Playground
|
||||
title: Kokoro TTS (Local)
|
||||
fireworks:
|
||||
description: fireworks.ai
|
||||
title: Fireworks.ai
|
||||
|
||||
@@ -96,6 +96,12 @@ watch(activeSpeechProvider, async (newProvider) => {
|
||||
syncOpenAICompatibleSettings()
|
||||
})
|
||||
|
||||
watch(activeSpeechModel, async () => {
|
||||
if (activeSpeechProvider.value) {
|
||||
await speechStore.loadVoicesForProvider(activeSpeechProvider.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Function to generate speech
|
||||
async function generateTestSpeech() {
|
||||
if (!testText.value.trim() && !useSSML.value)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import type { SpeechProvider } from '@xsai-ext/providers/utils'
|
||||
|
||||
import {
|
||||
SpeechPlayground,
|
||||
SpeechProviderSettings,
|
||||
} from '@proj-airi/stage-ui/components'
|
||||
import { useSpeechStore } from '@proj-airi/stage-ui/stores/modules/speech'
|
||||
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
|
||||
import { getDefaultKokoroModel } from '@proj-airi/stage-ui/workers/kokoro/constants'
|
||||
import { Callout, Select } from '@proj-airi/ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const providerId = 'kokoro-local'
|
||||
const defaultModel = 'kokoro-82m'
|
||||
const speechStore = useSpeechStore()
|
||||
const providersStore = useProvidersStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// Get available voices for Kokoro
|
||||
const availableVoices = computed(() => {
|
||||
return speechStore.availableVoices[providerId] || []
|
||||
})
|
||||
|
||||
// Get provider config
|
||||
const providerConfig = computed(() => {
|
||||
return providersStore.getProviderConfig(providerId)
|
||||
})
|
||||
|
||||
// Check if WebGPU is supported
|
||||
const hasWebGPU = ref(false)
|
||||
|
||||
// Track voices loading state
|
||||
const voicesLoading = ref(false)
|
||||
|
||||
// Get provider models from store
|
||||
const providerModels = computed(() => {
|
||||
return providersStore.getModelsForProvider(providerId)
|
||||
})
|
||||
|
||||
// Model loading state
|
||||
const modelsLoading = computed(() => {
|
||||
return providersStore.isLoadingModels[providerId] || false
|
||||
})
|
||||
|
||||
// Model computed property
|
||||
const model = computed({
|
||||
get(): string {
|
||||
const currentValue = providerConfig.value?.model as string
|
||||
if (currentValue)
|
||||
return currentValue
|
||||
|
||||
return getDefaultKokoroModel(hasWebGPU.value)
|
||||
},
|
||||
set(val: string) {
|
||||
const config = providersStore.getProviderConfig(providerId)
|
||||
config.model = val
|
||||
},
|
||||
})
|
||||
|
||||
// Model options for the dropdown
|
||||
const modelOptions = computed(() => {
|
||||
return providerModels.value.map(m => ({
|
||||
label: m.name,
|
||||
value: m.id,
|
||||
}))
|
||||
})
|
||||
|
||||
// Generate speech with Kokoro-specific parameters
|
||||
async function handleGenerateSpeech(input: string, voiceId: string, _useSSML: boolean) {
|
||||
try {
|
||||
const provider = await providersStore.getProviderInstance(providerId) as SpeechProvider
|
||||
if (!provider) {
|
||||
console.error('[Kokoro Playground] Failed to get provider instance')
|
||||
throw new Error('Failed to initialize speech provider')
|
||||
}
|
||||
|
||||
const config = providersStore.getProviderConfig(providerId)
|
||||
const selectedModel = config.model as string | undefined || defaultModel
|
||||
|
||||
const result = await speechStore.speech(
|
||||
provider,
|
||||
selectedModel,
|
||||
input,
|
||||
voiceId,
|
||||
{
|
||||
...config,
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[Kokoro Playground] Error generating speech:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Check WebGPU support
|
||||
hasWebGPU.value = typeof navigator !== 'undefined' && !!navigator.gpu
|
||||
|
||||
try {
|
||||
voicesLoading.value = true
|
||||
|
||||
// Fetch available models first
|
||||
await providersStore.fetchModelsForProvider(providerId)
|
||||
|
||||
const config = providersStore.getProviderConfig(providerId)
|
||||
const metadata = providersStore.getProviderMetadata(providerId)
|
||||
const validationResult = await metadata.validators.validateProviderConfig(config)
|
||||
if (validationResult.valid) {
|
||||
// Load the initial model
|
||||
if (metadata.capabilities.loadModel) {
|
||||
await metadata.capabilities.loadModel(config, {
|
||||
onProgress: async (_progress) => {},
|
||||
})
|
||||
}
|
||||
|
||||
await speechStore.loadVoicesForProvider(providerId)
|
||||
}
|
||||
else {
|
||||
console.error('Failed to validate Kokoro provider config', config, validationResult)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
voicesLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for model changes and reload model + voices
|
||||
watch(model, async (newValue) => {
|
||||
if (newValue) {
|
||||
try {
|
||||
voicesLoading.value = true
|
||||
|
||||
const config = providersStore.getProviderConfig(providerId)
|
||||
const metadata = providersStore.getProviderMetadata(providerId)
|
||||
const validationResult = await metadata.validators.validateProviderConfig(config)
|
||||
|
||||
if (validationResult.valid && metadata.capabilities.loadModel) {
|
||||
// Load the model using the capability with progress tracking
|
||||
await metadata.capabilities.loadModel(config, {
|
||||
onProgress: async (_progress) => {},
|
||||
})
|
||||
|
||||
// Then reload voices
|
||||
await speechStore.loadVoicesForProvider(providerId)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[Kokoro Settings] Error in model watcher:', error)
|
||||
}
|
||||
finally {
|
||||
voicesLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SpeechProviderSettings
|
||||
:provider-id="providerId"
|
||||
:default-model="defaultModel"
|
||||
>
|
||||
<template #voice-settings>
|
||||
<!-- Model Selection -->
|
||||
<div class="space-y-3">
|
||||
<Callout :label="t('settings.pages.providers.provider.kokoro-local.fields.field.model.label')">
|
||||
<div>
|
||||
<p>{{ t('settings.pages.providers.provider.kokoro-local.fields.field.model.description') }}</p>
|
||||
</div>
|
||||
</Callout>
|
||||
<div>
|
||||
<Select
|
||||
v-model="model"
|
||||
:options="modelOptions"
|
||||
:disabled="modelsLoading"
|
||||
placeholder="Choose a model..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Replace the default playground with our standalone component -->
|
||||
<template #playground>
|
||||
<SpeechPlayground
|
||||
:available-voices="availableVoices"
|
||||
:generate-speech="handleGenerateSpeech"
|
||||
:api-key-configured="true"
|
||||
:voices-loading="voicesLoading"
|
||||
:default-text="t('settings.pages.providers.provider.kokoro-local.playground.default-text')"
|
||||
/>
|
||||
</template>
|
||||
</SpeechProviderSettings>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
layout: settings
|
||||
stageTransition:
|
||||
name: slide
|
||||
</route>
|
||||
@@ -100,6 +100,7 @@
|
||||
"hono": "catalog:",
|
||||
"html2canvas": "^1.4.1",
|
||||
"idb-keyval": "catalog:",
|
||||
"kokoro-js": "^1.0.0",
|
||||
"localforage": "^1.10.0",
|
||||
"mediabunny": "^1.29.0",
|
||||
"nanoid": "^5.1.6",
|
||||
|
||||
@@ -19,6 +19,7 @@ const props = defineProps<{
|
||||
|
||||
// Current state
|
||||
apiKeyConfigured?: boolean
|
||||
voicesLoading?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -174,8 +175,8 @@ defineExpose({
|
||||
<button
|
||||
border="neutral-800 dark:neutral-200 solid 2" transition="border duration-250 ease-in-out"
|
||||
rounded-lg px-3 text="neutral-100 dark:neutral-900" py-1.5 text-sm
|
||||
:disabled="isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !selectedVoice || !apiKeyConfigured"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !selectedVoice || !apiKeyConfigured }"
|
||||
:disabled="isGenerating || voicesLoading || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !selectedVoice || !apiKeyConfigured"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': isGenerating || voicesLoading || (!testText.trim() && !useSSML) || (useSSML && !ssmlText.trim()) || !selectedVoice || !apiKeyConfigured }"
|
||||
bg="neutral-700 dark:neutral-300" @click="handleGenerateTestSpeech"
|
||||
>
|
||||
<div flex="~ row" items-center gap-2>
|
||||
@@ -187,8 +188,8 @@ defineExpose({
|
||||
<div v-if="!apiKeyConfigured" class="mt-2 text-sm text-red-500">
|
||||
{{ t('settings.pages.providers.provider.elevenlabs.playground.validation.error-missing-api-key') }}
|
||||
</div>
|
||||
<div v-if="!selectedVoice" class="mt-2 text-sm text-red-500">
|
||||
{{ t('settings.pages.modules.speech.sections.section.playground.select-voice.required') }}
|
||||
<div v-if="voicesLoading || !selectedVoice" class="mt-2 text-sm text-red-500">
|
||||
{{ voicesLoading ? t('settings.pages.modules.speech.sections.section.playground.select-voice.loading') : t('settings.pages.modules.speech.sections.section.playground.select-voice.required') }}
|
||||
</div>
|
||||
<div v-if="errorMessage" class="mt-2 text-sm text-red-500">
|
||||
{{ errorMessage }}
|
||||
|
||||
@@ -60,6 +60,8 @@ import {
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { getKokoroWorker } from '../workers/kokoro'
|
||||
import { getDefaultKokoroModel, KOKORO_MODELS, kokoroModelsToModelInfo } from '../workers/kokoro/constants'
|
||||
import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './providers/aliyun/stream-transcription'
|
||||
import { models as elevenLabsModels } from './providers/elevenlabs/list-models'
|
||||
import { buildOpenAICompatibleProvider } from './providers/openai-compatible-builder'
|
||||
@@ -2193,6 +2195,194 @@ export const useProvidersStore = defineStore('providers', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
'kokoro-local': {
|
||||
id: 'kokoro-local',
|
||||
category: 'speech',
|
||||
tasks: ['text-to-speech'],
|
||||
nameKey: 'settings.pages.providers.provider.kokoro-local.title',
|
||||
name: 'Kokoro TTS',
|
||||
descriptionKey: 'settings.pages.providers.provider.kokoro-local.description',
|
||||
description: 'Local text-to-speech using Kokoro-82M.',
|
||||
icon: 'i-lobe-icons:speaker',
|
||||
|
||||
defaultOptions: () => {
|
||||
const hasWebGPU = typeof navigator !== 'undefined' && !!navigator.gpu
|
||||
const model = getDefaultKokoroModel(hasWebGPU)
|
||||
return {
|
||||
model,
|
||||
voiceId: '',
|
||||
}
|
||||
},
|
||||
|
||||
createProvider: async (_config) => {
|
||||
// Import the worker manager
|
||||
const workerManagerPromise = getKokoroWorker()
|
||||
|
||||
const provider: SpeechProvider = {
|
||||
speech: () => {
|
||||
return {
|
||||
baseURL: 'http://kokoro-local/v1/',
|
||||
model: 'kokoro-82m',
|
||||
fetch: async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
try {
|
||||
// Parse OpenAI-compatible request body
|
||||
if (!init?.body || typeof init.body !== 'string') {
|
||||
throw new Error('Invalid request body')
|
||||
}
|
||||
const body = JSON.parse(init.body)
|
||||
const text = body.input
|
||||
const voice = body.voice
|
||||
|
||||
if (!voice) {
|
||||
throw new Error('Voice parameter is required')
|
||||
}
|
||||
|
||||
// Generate audio in the worker thread
|
||||
const buffer = await (await workerManagerPromise).generate(text, voice)
|
||||
|
||||
return new Response(buffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'audio/wav',
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Kokoro TTS generation failed:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return provider
|
||||
},
|
||||
|
||||
capabilities: {
|
||||
listModels: async (_config: Record<string, unknown>) => {
|
||||
const hasWebGPU = typeof navigator !== 'undefined' && !!navigator.gpu
|
||||
return kokoroModelsToModelInfo(hasWebGPU, t)
|
||||
},
|
||||
|
||||
loadModel: async (config: Record<string, unknown>, _hooks?: { onProgress?: (progress: ProgressInfo) => Promise<void> | void }) => {
|
||||
const modelId = config.model as string
|
||||
|
||||
if (!modelId) {
|
||||
throw new Error('No model specified')
|
||||
}
|
||||
|
||||
const modelDef = KOKORO_MODELS.find(m => m.id === modelId)
|
||||
if (!modelDef) {
|
||||
throw new Error(`Invalid model: ${modelId}. Must be one of: ${KOKORO_MODELS.map(m => m.id).join(', ')}`)
|
||||
}
|
||||
|
||||
// Validate platform requirements
|
||||
if (modelDef.platform === 'webgpu') {
|
||||
const hasWebGPU = typeof navigator !== 'undefined' && !!navigator.gpu
|
||||
if (!hasWebGPU) {
|
||||
throw new Error('WebGPU is required for this model but is not available in your browser')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const workerManager = await getKokoroWorker()
|
||||
await workerManager.loadModel(modelDef.quantization, modelDef.platform, { onProgress: _hooks?.onProgress })
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to load Kokoro model:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
listVoices: async (config: Record<string, unknown>) => {
|
||||
try {
|
||||
// Reload the model before fetching voices
|
||||
const modelId = config.model as string
|
||||
if (modelId) {
|
||||
const modelDef = KOKORO_MODELS.find(m => m.id === modelId)
|
||||
if (modelDef) {
|
||||
// Validate platform requirements
|
||||
if (modelDef.platform === 'webgpu') {
|
||||
const hasWebGPU = typeof navigator !== 'undefined' && !!navigator.gpu
|
||||
if (!hasWebGPU) {
|
||||
throw new Error('WebGPU is required for this model but is not available in your browser')
|
||||
}
|
||||
}
|
||||
|
||||
// Load the model
|
||||
const workerManager = await getKokoroWorker()
|
||||
await workerManager.loadModel(modelDef.quantization, modelDef.platform)
|
||||
}
|
||||
}
|
||||
|
||||
// Get worker manager and fetch voices from the model
|
||||
const workerManager = await getKokoroWorker()
|
||||
const modelVoices = workerManager.getVoices()
|
||||
|
||||
// Language code mapping
|
||||
const languageMap: Record<string, { code: string, title: string }> = {
|
||||
'en-us': { code: 'en-US', title: 'English (US)' },
|
||||
'en-gb': { code: 'en-GB', title: 'English (UK)' },
|
||||
'ja': { code: 'ja', title: 'Japanese' },
|
||||
'zh-cn': { code: 'zh-CN', title: 'Chinese (Mandarin)' },
|
||||
'es': { code: 'es', title: 'Spanish' },
|
||||
'fr': { code: 'fr', title: 'French' },
|
||||
'hi': { code: 'hi', title: 'Hindi' },
|
||||
'it': { code: 'it', title: 'Italian' },
|
||||
'pt-br': { code: 'pt-BR', title: 'Portuguese (Brazil)' },
|
||||
}
|
||||
|
||||
// Transform the voices object to the expected array format
|
||||
return Object.entries(modelVoices).map(([id, voice]: [string, { language: string, name: string, gender: string }]) => {
|
||||
const languageCode = voice.language.toLowerCase()
|
||||
const languageInfo = languageMap[languageCode] || { code: languageCode, title: voice.language }
|
||||
|
||||
return {
|
||||
id,
|
||||
name: `${voice.name} (${voice.gender}, ${languageInfo.title.split('(')[0].trim()})`,
|
||||
provider: 'kokoro-local',
|
||||
languages: [languageInfo],
|
||||
gender: voice.gender.toLowerCase(),
|
||||
}
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to fetch Kokoro voices:', error)
|
||||
// Return empty array if model not loaded yet
|
||||
return []
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
validators: {
|
||||
validateProviderConfig: async (config: any) => {
|
||||
const model = config.model as string
|
||||
|
||||
if (!model) {
|
||||
return {
|
||||
errors: [new Error('No model selected')],
|
||||
reason: 'Please select a model from the dropdown menu',
|
||||
valid: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (!KOKORO_MODELS.some(m => m.id === model)) {
|
||||
return {
|
||||
errors: [new Error(`Invalid model: ${model}`)],
|
||||
reason: `Invalid model. Must be one of: ${KOKORO_MODELS.map(m => m.id).join(', ')}`,
|
||||
valid: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errors: [],
|
||||
reason: '',
|
||||
valid: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// const validatedCredentials = ref<Record<string, string>>({})
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Kokoro TTS Constants
|
||||
* Centralized constants for Kokoro TTS to avoid duplication
|
||||
*/
|
||||
|
||||
/**
|
||||
* Platform types for Kokoro models
|
||||
*/
|
||||
export type KokoroPlatform = 'webgpu' | 'wasm'
|
||||
|
||||
/**
|
||||
* Kokoro model definition
|
||||
*/
|
||||
export interface KokoroModel {
|
||||
/** Model identifier/quantization string */
|
||||
id: string
|
||||
/** Human-readable name */
|
||||
name: string
|
||||
/** Platform required to run this model */
|
||||
platform: KokoroPlatform
|
||||
/** Quantization value to pass to loadModel */
|
||||
quantization: string
|
||||
/** i18n key for model description */
|
||||
descriptionKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Available Kokoro models with their platform requirements
|
||||
*/
|
||||
export const KOKORO_MODELS = [
|
||||
{
|
||||
id: 'fp32-webgpu',
|
||||
name: 'FP32 (WebGPU)',
|
||||
platform: 'webgpu',
|
||||
quantization: 'fp32',
|
||||
descriptionKey: 'settings.pages.providers.provider.kokoro-local.models.fp32-webgpu.description',
|
||||
},
|
||||
{
|
||||
id: 'fp32',
|
||||
name: 'FP32 (WASM)',
|
||||
platform: 'wasm',
|
||||
quantization: 'fp32',
|
||||
descriptionKey: 'settings.pages.providers.provider.kokoro-local.models.fp32.description',
|
||||
},
|
||||
{
|
||||
id: 'fp16',
|
||||
name: 'FP16 (WASM)',
|
||||
platform: 'wasm',
|
||||
quantization: 'fp16',
|
||||
descriptionKey: 'settings.pages.providers.provider.kokoro-local.models.fp16.description',
|
||||
},
|
||||
{
|
||||
id: 'q8',
|
||||
name: 'Q8 (WASM)',
|
||||
platform: 'wasm',
|
||||
quantization: 'q8',
|
||||
descriptionKey: 'settings.pages.providers.provider.kokoro-local.models.q8.description',
|
||||
},
|
||||
{
|
||||
id: 'q4',
|
||||
name: 'Q4 (WASM)',
|
||||
platform: 'wasm',
|
||||
quantization: 'q4',
|
||||
descriptionKey: 'settings.pages.providers.provider.kokoro-local.models.q4.description',
|
||||
},
|
||||
{
|
||||
id: 'q4f16',
|
||||
name: 'Q4F16 (WASM)',
|
||||
platform: 'wasm',
|
||||
quantization: 'q4f16',
|
||||
descriptionKey: 'settings.pages.providers.provider.kokoro-local.models.q4f16.description',
|
||||
},
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Type for Kokoro quantization options
|
||||
*/
|
||||
export type KokoroQuantization = typeof KOKORO_MODELS[number]['id']
|
||||
|
||||
/**
|
||||
* Convert Kokoro models to ModelInfo array
|
||||
* @param hasWebGPU - Whether WebGPU is available (filters out WebGPU models if false)
|
||||
* @param t - Optional translation function for i18n support
|
||||
* @returns Array of ModelInfo objects
|
||||
*/
|
||||
export function kokoroModelsToModelInfo(hasWebGPU: boolean, t?: (key: string) => string) {
|
||||
return KOKORO_MODELS
|
||||
.filter(model => hasWebGPU || model.platform !== 'webgpu')
|
||||
.map(model => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
provider: 'kokoro-local',
|
||||
description: t ? t(model.descriptionKey) : model.descriptionKey,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default model based on WebGPU availability
|
||||
* @param hasWebGPU - Whether WebGPU is available
|
||||
* @returns The default model to use
|
||||
*/
|
||||
export function getDefaultKokoroModel(hasWebGPU: boolean): KokoroQuantization {
|
||||
return hasWebGPU ? 'fp32-webgpu' : 'q4f16'
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Kokoro TTS Worker Manager
|
||||
* Manages communication with the Kokoro TTS worker thread
|
||||
*/
|
||||
|
||||
import type { LoadedMessage, VoiceKey, Voices, WorkerRequest, WorkerResponse } from './types'
|
||||
|
||||
/**
|
||||
* An async mutex that ensures only one callback runs at a time.
|
||||
* Waiters queue up and are processed in FIFO order.
|
||||
*/
|
||||
class AsyncMutex {
|
||||
private locked = false
|
||||
private waiters: { resolve: () => void, reject: (error: Error) => void }[] = []
|
||||
|
||||
// Incremented on reset() to invalidate stale lock holders
|
||||
private generation = 0
|
||||
|
||||
/**
|
||||
* Executes the callback with exclusive access to the mutex.
|
||||
* If the mutex is locked, waits in queue until it's our turn.
|
||||
*/
|
||||
async run<T>(callback: () => Promise<T> | T): Promise<T> {
|
||||
const myGeneration = this.generation
|
||||
|
||||
if (this.locked) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.waiters.push({ resolve, reject })
|
||||
})
|
||||
if (myGeneration !== this.generation) {
|
||||
throw new Error('Mutex was reset')
|
||||
}
|
||||
}
|
||||
this.locked = true
|
||||
|
||||
try {
|
||||
return await callback()
|
||||
}
|
||||
finally {
|
||||
if (myGeneration === this.generation) {
|
||||
const next = this.waiters.shift()
|
||||
if (next) {
|
||||
next.resolve()
|
||||
}
|
||||
else {
|
||||
this.locked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all waiting tasks and releases the lock.
|
||||
* Atomic thanks to JavaScript's Run-To-Completion semantics.
|
||||
*/
|
||||
reset(error: Error = new Error('Mutex reset')): void {
|
||||
this.generation++
|
||||
this.locked = false
|
||||
|
||||
const waitersToReject = this.waiters
|
||||
this.waiters = []
|
||||
|
||||
for (const waiter of waitersToReject) {
|
||||
waiter.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitForEvent<T extends Event>(
|
||||
element: EventTarget,
|
||||
eventName: string,
|
||||
predicate: (event: T) => boolean = () => true,
|
||||
callback: (event: T) => void = () => {},
|
||||
): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
const listener = (event: Event) => {
|
||||
const typedEvent = event as T
|
||||
|
||||
if (predicate(typedEvent)) {
|
||||
element.removeEventListener(eventName, listener)
|
||||
resolve(typedEvent)
|
||||
}
|
||||
else {
|
||||
callback(typedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener(eventName, listener)
|
||||
})
|
||||
}
|
||||
|
||||
export class KokoroWorkerManager {
|
||||
private worker: Worker | null = null
|
||||
private asyncMutex: AsyncMutex
|
||||
private workerLifecycleAsyncMutex: AsyncMutex
|
||||
private voices: Voices | null = null
|
||||
|
||||
private restartAttempts = 0
|
||||
private readonly maxRestartAttempts = 3
|
||||
private readonly restartDelayMs = 1000
|
||||
|
||||
constructor() {
|
||||
this.workerLifecycleAsyncMutex = new AsyncMutex()
|
||||
this.asyncMutex = new AsyncMutex()
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
await this.workerLifecycleAsyncMutex.run(async () => {
|
||||
// Only initialize if not already running
|
||||
if (!this.worker) {
|
||||
this.initializeWorker()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private initializeWorker(): void {
|
||||
this.worker = new Worker(new URL('./worker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
})
|
||||
|
||||
this.worker.addEventListener('error', (event) => {
|
||||
this.handleWorkerError(event)
|
||||
})
|
||||
}
|
||||
|
||||
private handleWorkerError(event: ErrorEvent): void {
|
||||
const error = new Error(event.message || 'An unknown worker error occurred')
|
||||
|
||||
// Reject all pending operations
|
||||
this.asyncMutex.reset(error)
|
||||
|
||||
// Clean up current worker
|
||||
this.terminate()
|
||||
|
||||
// Attempt restart with backoff
|
||||
this.scheduleRestart()
|
||||
}
|
||||
|
||||
private scheduleRestart(): void {
|
||||
if (this.restartAttempts >= this.maxRestartAttempts) {
|
||||
console.error(
|
||||
`[KokoroWorker] Max restart attempts (${this.maxRestartAttempts}) reached. Giving up.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.restartAttempts++
|
||||
const delay = this.restartDelayMs * this.restartAttempts // Linear backoff
|
||||
|
||||
console.warn(
|
||||
`[KokoroWorker] Restarting worker in ${delay}ms (attempt ${this.restartAttempts}/${this.maxRestartAttempts})`,
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
this.start().catch((err) => {
|
||||
console.error('[KokoroWorker] Failed to restart worker:', err)
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// Call this after successful operations to reset the counter
|
||||
private onSuccessfulOperation(): void {
|
||||
this.restartAttempts = 0
|
||||
}
|
||||
|
||||
async loadModel(quantization: string, device: string, options?: { onProgress?: (progress: any) => void }): Promise<Voices> {
|
||||
// Lazy-start the worker if not already initialized
|
||||
await this.start()
|
||||
return await this.asyncMutex.run(async () => {
|
||||
const voicePromise = waitForEvent<MessageEvent<WorkerResponse>>(
|
||||
this.worker!,
|
||||
'message',
|
||||
event => event.data.type === 'loaded',
|
||||
(event) => {
|
||||
if (event.data.type === 'progress' && options?.onProgress) {
|
||||
options.onProgress(event.data.progress)
|
||||
}
|
||||
},
|
||||
)
|
||||
const message: WorkerRequest = {
|
||||
type: 'load',
|
||||
data: { quantization, device },
|
||||
}
|
||||
this.worker!.postMessage(message)
|
||||
const event = await voicePromise
|
||||
const loadedData = event.data as LoadedMessage
|
||||
this.voices = loadedData.voices
|
||||
this.onSuccessfulOperation()
|
||||
return this.voices
|
||||
})
|
||||
}
|
||||
|
||||
async generate(text: string, voice: VoiceKey): Promise<ArrayBuffer> {
|
||||
return await this.asyncMutex.run(async () => {
|
||||
if (!this.worker) {
|
||||
throw new Error('Worker not initialized. Call start() first.')
|
||||
}
|
||||
|
||||
const resultPromise = waitForEvent<MessageEvent<WorkerResponse>>(
|
||||
this.worker,
|
||||
'message',
|
||||
event => event.data.type === 'result',
|
||||
)
|
||||
const message: WorkerRequest = {
|
||||
type: 'generate',
|
||||
data: { text, voice },
|
||||
}
|
||||
this.worker.postMessage(message)
|
||||
const event = await resultPromise
|
||||
const response = event.data
|
||||
|
||||
if ('status' in response) {
|
||||
switch (response.status) {
|
||||
case 'success':
|
||||
this.onSuccessfulOperation()
|
||||
return response.buffer
|
||||
case 'error':
|
||||
throw new Error(response.message)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unexpected response from worker')
|
||||
})
|
||||
}
|
||||
|
||||
getVoices(): Voices {
|
||||
if (!this.voices) {
|
||||
throw new Error('Model not loaded. Call loadModel() first.')
|
||||
}
|
||||
return this.voices
|
||||
}
|
||||
|
||||
private terminate(): void {
|
||||
if (this.worker) {
|
||||
this.worker.terminate()
|
||||
this.worker = null
|
||||
}
|
||||
this.voices = null
|
||||
}
|
||||
}
|
||||
|
||||
let globalWorkerManager: KokoroWorkerManager | null = null
|
||||
const globalWorkerManagerGetterLock: AsyncMutex = new AsyncMutex()
|
||||
|
||||
export async function getKokoroWorker(): Promise<KokoroWorkerManager> {
|
||||
return globalWorkerManagerGetterLock.run(async () => {
|
||||
if (!globalWorkerManager) {
|
||||
globalWorkerManager = new KokoroWorkerManager()
|
||||
await globalWorkerManager.start()
|
||||
}
|
||||
return globalWorkerManager
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Type definitions for Kokoro Worker messages
|
||||
*/
|
||||
|
||||
import type { GenerateOptions } from 'kokoro-js'
|
||||
|
||||
export type VoiceKey = NonNullable<GenerateOptions['voice']>
|
||||
|
||||
export interface Voice {
|
||||
language: string
|
||||
name: string
|
||||
gender: string
|
||||
}
|
||||
|
||||
export type Voices = Record<string, Voice>
|
||||
|
||||
// Messages sent TO the worker
|
||||
export interface LoadMessage {
|
||||
type: 'load'
|
||||
data: {
|
||||
quantization: string
|
||||
device: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenerateMessage {
|
||||
type: 'generate'
|
||||
data: {
|
||||
text: string
|
||||
voice: VoiceKey
|
||||
}
|
||||
}
|
||||
|
||||
export type WorkerRequest = LoadMessage | GenerateMessage
|
||||
|
||||
// Messages received FROM the worker
|
||||
export interface ProgressMessage {
|
||||
type: 'progress'
|
||||
progress: any
|
||||
}
|
||||
|
||||
export interface LoadedMessage {
|
||||
type: 'loaded'
|
||||
voices: Voices
|
||||
}
|
||||
|
||||
export interface SuccessMessage {
|
||||
type: 'result'
|
||||
status: 'success'
|
||||
buffer: ArrayBuffer
|
||||
}
|
||||
|
||||
export interface ErrorMessage {
|
||||
type: 'result'
|
||||
status: 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type WorkerResponse = ProgressMessage | LoadedMessage | SuccessMessage | ErrorMessage
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Kokoro TTS Web Worker Entry Point
|
||||
* This file is imported as a Web Worker
|
||||
*/
|
||||
|
||||
import type { ErrorMessage, LoadedMessage, ProgressMessage, SuccessMessage, VoiceKey, WorkerRequest } from './types'
|
||||
|
||||
import { KokoroTTS } from 'kokoro-js'
|
||||
|
||||
let ttsModel: KokoroTTS | null = null
|
||||
let currentQuantization: string | null = null
|
||||
let currentDevice: string | null = null
|
||||
|
||||
interface GenerateRequest {
|
||||
text: string
|
||||
voice: VoiceKey
|
||||
}
|
||||
|
||||
async function loadModel(quantization: string, device: string) {
|
||||
// Check if we already have the correct model loaded
|
||||
if (ttsModel && currentQuantization === quantization && currentDevice === device) {
|
||||
const message: LoadedMessage = {
|
||||
type: 'loaded',
|
||||
voices: ttsModel.voices,
|
||||
}
|
||||
globalThis.postMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
// Map fp32-webgpu to fp32 for the model
|
||||
const modelQuantization = quantization === 'fp32-webgpu' ? 'fp32' : quantization
|
||||
|
||||
ttsModel = await KokoroTTS.from_pretrained(
|
||||
'onnx-community/Kokoro-82M-v1.0-ONNX',
|
||||
{
|
||||
dtype: modelQuantization as 'fp32' | 'fp16' | 'q8' | 'q4' | 'q4f16',
|
||||
device: device as 'wasm' | 'webgpu' | 'cpu',
|
||||
progress_callback: (progress) => {
|
||||
const message: ProgressMessage = {
|
||||
type: 'progress',
|
||||
progress,
|
||||
}
|
||||
globalThis.postMessage(message)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Store the current settings
|
||||
currentQuantization = quantization
|
||||
currentDevice = device
|
||||
|
||||
const message: LoadedMessage = {
|
||||
type: 'loaded',
|
||||
voices: ttsModel.voices,
|
||||
}
|
||||
globalThis.postMessage(message)
|
||||
}
|
||||
|
||||
async function generate(request: GenerateRequest) {
|
||||
const { text, voice } = request
|
||||
|
||||
if (!ttsModel) {
|
||||
const errorMessage: ErrorMessage = {
|
||||
type: 'result',
|
||||
status: 'error',
|
||||
message: 'Kokoro TTS generation failed: No model loaded.',
|
||||
}
|
||||
globalThis.postMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate audio from text
|
||||
const result = await ttsModel.generate(text, {
|
||||
voice,
|
||||
})
|
||||
|
||||
const blob = await result.toBlob()
|
||||
const buffer: ArrayBuffer = await blob.arrayBuffer()
|
||||
|
||||
// Send the audio buffer back to the main thread
|
||||
// Use transferable to avoid copying the buffer
|
||||
const successMessage: SuccessMessage = {
|
||||
type: 'result',
|
||||
status: 'success',
|
||||
buffer,
|
||||
}
|
||||
const transferList: ArrayBuffer[] = [buffer]
|
||||
;(globalThis as any).postMessage(successMessage, transferList)
|
||||
}
|
||||
|
||||
// Listen for messages from the main thread
|
||||
globalThis.addEventListener('message', async (event: MessageEvent<WorkerRequest>) => {
|
||||
const message = event.data
|
||||
|
||||
switch (message.type) {
|
||||
case 'load':
|
||||
await loadModel(message.data.quantization, message.data.device)
|
||||
break
|
||||
|
||||
case 'generate':
|
||||
await generate(message.data)
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn('[Kokoro Worker] Unknown message type:', (message as any).type)
|
||||
}
|
||||
})
|
||||
Generated
+16
@@ -2416,6 +2416,9 @@ importers:
|
||||
idb-keyval:
|
||||
specifier: 'catalog:'
|
||||
version: 6.2.2
|
||||
kokoro-js:
|
||||
specifier: ^1.0.0
|
||||
version: 1.2.1
|
||||
localforage:
|
||||
specifier: ^1.10.0
|
||||
version: 1.10.0
|
||||
@@ -12548,6 +12551,9 @@ packages:
|
||||
knitwork@1.3.0:
|
||||
resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==}
|
||||
|
||||
kokoro-js@1.2.1:
|
||||
resolution: {integrity: sha512-oq0HZJWis3t8lERkMJh84WLU86dpYD0EuBPtqYnLlQzyFP1OkyBRDcweAqCfhNOpltyN9j/azp1H6uuC47gShw==}
|
||||
|
||||
kolorist@1.8.0:
|
||||
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
||||
|
||||
@@ -13860,6 +13866,9 @@ packages:
|
||||
pgpass@1.0.5:
|
||||
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||
|
||||
phonemizer@1.2.1:
|
||||
resolution: {integrity: sha512-v0KJ4mi2T4Q7eJQ0W15Xd4G9k4kICSXE8bpDeJ8jisL4RyJhNWsweKTOi88QXFc4r4LZlz5jVL5lCHhkpdT71A==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -27029,6 +27038,11 @@ snapshots:
|
||||
knitwork@1.3.0:
|
||||
optional: true
|
||||
|
||||
kokoro-js@1.2.1:
|
||||
dependencies:
|
||||
'@huggingface/transformers': 3.8.1
|
||||
phonemizer: 1.2.1
|
||||
|
||||
kolorist@1.8.0: {}
|
||||
|
||||
ky@1.14.2: {}
|
||||
@@ -28676,6 +28690,8 @@ snapshots:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
phonemizer@1.2.1: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
Reference in New Issue
Block a user