feat(stage-ui): add Kokoro TTS (Local) speech provider (#1005)

This commit is contained in:
Sebastián
2026-02-01 23:34:52 +08:00
committed by GitHub
parent 0e9b0668de
commit 47e444bd85
12 changed files with 980 additions and 5 deletions
+10 -1
View File
@@ -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>
+1
View File
@@ -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 }}
+190
View File
@@ -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)
}
})
+16
View File
@@ -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: {}