openreader/src/utils/voice.ts
Richard Roberson 1dcffc82d8 feat(tts,ui,api): add Kokoro multi-voice selection and SDK support
- Introduce voice utils (model detection, voice parsing/weights, limits)
- Enable Kokoro multi-voice strings across OpenAI/Deepinfra/custom providers
- Normalize non-Kokoro voices to single token for SDK calls
- Expose full Kokoro voice list for custom-openai Kokoro models
- Update TTS API to return audio as ArrayBuffer and improve logging
- Add multi-select UI for Kokoro voices with provider-based clamping
- Preserve Kokoro voice strings in TTSContext and coalesce restarts
- Merge multi-sentence quoted dialogue in NLP sentence splitter
- Update tests for single and multi-voice selection flows
2025-11-12 20:46:45 -07:00

74 lines
2.2 KiB
TypeScript

/**
* Voice Utilities
*
* This module provides utilities for handling voice selection and management,
* particularly for Kokoro multi-voice syntax.
*/
/**
* Parses a Kokoro voice string into individual voice names
* Strips weights like "af_heart(0.5)" -> "af_heart"
*
* @param voiceString - Voice string to parse (e.g., "af_heart(0.5)+bf_emma(0.5)")
* @returns Array of voice names without weights
*/
export const parseKokoroVoiceNames = (voiceString: string): string[] =>
voiceString
.split('+')
.map(s => s.trim())
.filter(Boolean)
.map(s => s.replace(/\([^)]*\)/g, '').trim());
/**
* Builds a Kokoro voice string from an array of voice names
* Automatically calculates equal weights for multiple voices
*
* @param names - Array of voice names
* @returns Formatted voice string with weights or single voice name
*/
export const buildKokoroVoiceString = (names: string[]): string => {
const n = names.length;
if (n === 0) return '';
if (n === 1) return names[0];
const weight = 1 / n;
const weightString = weight.toFixed(2).replace(/0+$/, '').replace(/\.$/, '');
return names.map(name => `${name}(${weightString})`).join('+');
};
/**
* Checks if a model name is a Kokoro model
*
* @param modelName - TTS model name
* @returns True if the model is a Kokoro model
*/
export const isKokoroModel = (modelName: string | undefined): boolean => {
return (modelName || '').toLowerCase().includes('kokoro');
};
/**
* Strips weight annotations from a voice string
*
* @param voiceString - Voice string with or without weights
* @returns Voice string without weights
*/
export const stripVoiceWeights = (voiceString: string): string => {
return voiceString.replace(/\([^)]*\)/g, '').trim();
};
/**
* Determines the maximum number of voices allowed for a provider/model combination
*
* @param provider - TTS provider name
* @param model - TTS model name
* @returns Maximum number of voices (Infinity for unlimited)
*/
export const getMaxVoicesForProvider = (provider: string, model: string): number => {
if (!isKokoroModel(model)) return 1;
// Deepinfra Kokoro supports up to 2 voices
if (provider === 'deepinfra') return 2;
// Other providers with Kokoro support unlimited voices
return Infinity;
};