793 lines
28 KiB
JavaScript
793 lines
28 KiB
JavaScript
/**
|
|
* Kokoro TTS Handler
|
|
* Provides neural TTS via Kokoro.js
|
|
*/
|
|
import { TTSHandler } from './tts-handler.js';
|
|
import { moduleRegistry } from './module-registry.js';
|
|
|
|
export class KokoroHandler extends TTSHandler {
|
|
constructor() {
|
|
super();
|
|
this.id = 'kokoro';
|
|
this.name = 'Kokoro TTS Handler';
|
|
|
|
// Kokoro instance
|
|
this.kokoro = null;
|
|
|
|
// Available voices
|
|
this.voices = [
|
|
{ id: 'de_DE-neural', name: 'German (Neural)', lang: 'de-DE' },
|
|
{ id: 'en_US-neural', name: 'English (Neural)', lang: 'en-US' }
|
|
];
|
|
|
|
// Current voice
|
|
this.currentVoice = null;
|
|
|
|
// Voice options
|
|
this.options = {
|
|
volume: 1.0,
|
|
rate: 1.0,
|
|
pitch: 1.0
|
|
};
|
|
|
|
// State
|
|
this.available = false;
|
|
this.loading = false;
|
|
this.currentAudio = null;
|
|
this.preloadCache = new Map();
|
|
this.worker = null;
|
|
this.pendingGeneration = null;
|
|
|
|
// Dependencies
|
|
this.dependencies = ['localization', 'persistence-manager'];
|
|
|
|
// Bind methods
|
|
this.bindMethods([
|
|
'initialize',
|
|
'speak',
|
|
'speakPreloaded',
|
|
'preloadSpeech',
|
|
'stop',
|
|
'pause',
|
|
'resume',
|
|
'getVoices',
|
|
'setVoice',
|
|
'setOptions',
|
|
'setupVoiceFromPreferences',
|
|
'getId',
|
|
'getModule'
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Initialize the Kokoro TTS handler
|
|
* @param {Function} progressCallback - Callback for progress updates
|
|
* @returns {Promise<boolean>} - Resolves with success status
|
|
*/
|
|
async initialize(progressCallback = null) {
|
|
if (this.loading) {
|
|
return new Promise((resolve) => {
|
|
// Wait for loading to complete
|
|
this.addEventListener(document, 'kokoro-loading-complete', (event) => {
|
|
resolve(event.detail?.success || false);
|
|
}, { once: true });
|
|
});
|
|
}
|
|
|
|
if (this.available) {
|
|
return true;
|
|
}
|
|
|
|
this.loading = true;
|
|
|
|
try {
|
|
// Report progress
|
|
if (progressCallback) {
|
|
progressCallback(10, "Loading Kokoro TTS");
|
|
}
|
|
|
|
// Initialize web worker
|
|
try {
|
|
if (progressCallback) {
|
|
progressCallback(20, "Initializing Kokoro worker");
|
|
}
|
|
|
|
// Create worker
|
|
this.worker = new Worker('/js/kokoro-worker.js');
|
|
|
|
// Set up message handler
|
|
this.worker.onmessage = (e) => {
|
|
const message = e.data;
|
|
|
|
switch (message.type) {
|
|
case 'ready':
|
|
console.log('Kokoro worker is ready');
|
|
break;
|
|
|
|
case 'generated':
|
|
// Handle generated speech
|
|
if (this.pendingGeneration) {
|
|
const { text, resolve, reject } = this.pendingGeneration;
|
|
this.pendingGeneration = null;
|
|
|
|
try {
|
|
// Create audio from the returned buffer
|
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
const audioBuffer = audioContext.createBuffer(
|
|
1, // mono
|
|
message.result.audio.length,
|
|
message.result.sampling_rate
|
|
);
|
|
|
|
// Copy the audio data to the buffer
|
|
const channelData = audioBuffer.getChannelData(0);
|
|
channelData.set(new Float32Array(message.result.audio));
|
|
|
|
// Create audio element
|
|
const audio = new Audio();
|
|
const source = audioContext.createBufferSource();
|
|
source.buffer = audioBuffer;
|
|
|
|
// Connect to destination
|
|
source.connect(audioContext.destination);
|
|
|
|
// Create a play function
|
|
const play = () => {
|
|
source.start(0);
|
|
};
|
|
|
|
resolve({ audio, play, buffer: audioBuffer });
|
|
} catch (error) {
|
|
console.error('Error processing Kokoro audio:', error);
|
|
reject(error);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'error':
|
|
console.error('Kokoro worker error:', message.error);
|
|
if (this.pendingGeneration) {
|
|
const { reject } = this.pendingGeneration;
|
|
this.pendingGeneration = null;
|
|
reject(new Error(message.error));
|
|
}
|
|
break;
|
|
|
|
case 'progress':
|
|
if (progressCallback) {
|
|
const progress = 20 + Math.round(message.progress * 80);
|
|
progressCallback(progress, `Loading Kokoro model: ${Math.round(message.progress * 100)}%`);
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
// Set up error handler
|
|
this.worker.onerror = (error) => {
|
|
console.error('Kokoro worker error:', error);
|
|
if (this.pendingGeneration) {
|
|
const { reject } = this.pendingGeneration;
|
|
this.pendingGeneration = null;
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
// Initialize the worker
|
|
this.worker.postMessage({ type: 'init' });
|
|
|
|
// Wait for worker to be ready
|
|
await new Promise((resolve, reject) => {
|
|
// Set up message handler for initialization
|
|
const messageHandler = (e) => {
|
|
const message = e.data;
|
|
|
|
if (message.type === 'ready') {
|
|
this.worker.removeEventListener('message', messageHandler);
|
|
resolve();
|
|
} else if (message.type === 'error') {
|
|
this.worker.removeEventListener('message', messageHandler);
|
|
reject(new Error(message.error));
|
|
}
|
|
};
|
|
|
|
this.worker.addEventListener('message', messageHandler);
|
|
|
|
// Set timeout for initialization
|
|
setTimeout(() => {
|
|
this.worker.removeEventListener('message', messageHandler);
|
|
reject(new Error('Timeout initializing Kokoro worker'));
|
|
}, 30000);
|
|
});
|
|
|
|
console.log('Kokoro worker initialized successfully');
|
|
|
|
// Mark as available
|
|
this.available = true;
|
|
|
|
// Set up voice based on preferences and locale
|
|
await this.setupVoiceFromPreferences();
|
|
|
|
// Report progress
|
|
if (progressCallback) {
|
|
progressCallback(100, "Kokoro TTS ready");
|
|
}
|
|
|
|
// Dispatch event
|
|
this.dispatchEvent('kokoro-loading-complete', { success: true });
|
|
|
|
this.loading = false;
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Error initializing Kokoro worker:', error);
|
|
|
|
// Try to fall back to direct method
|
|
try {
|
|
if (progressCallback) {
|
|
progressCallback(20, "Initializing Kokoro directly");
|
|
}
|
|
|
|
// Load Kokoro script
|
|
await new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = '/js/kokoro.js';
|
|
script.onload = () => {
|
|
if (window.kokoro) {
|
|
this.kokoro = window.kokoro;
|
|
resolve();
|
|
} else {
|
|
reject(new Error('Kokoro not found after script load'));
|
|
}
|
|
};
|
|
script.onerror = (e) => {
|
|
reject(new Error('Error loading Kokoro script'));
|
|
};
|
|
document.head.appendChild(script);
|
|
|
|
// Set timeout
|
|
setTimeout(() => {
|
|
reject(new Error('Timeout loading Kokoro script'));
|
|
}, 10000);
|
|
});
|
|
|
|
// Function to report progress
|
|
const progress = (progress) => {
|
|
if (progressCallback) {
|
|
const scaledProgress = 20 + Math.round(progress * 80);
|
|
progressCallback(scaledProgress, `Loading Kokoro model: ${Math.round(progress * 100)}%`);
|
|
}
|
|
};
|
|
|
|
// Initialize Kokoro
|
|
await this.kokoro.init({
|
|
progress
|
|
});
|
|
|
|
console.log('Kokoro initialized successfully');
|
|
|
|
// Mark as available
|
|
this.available = true;
|
|
|
|
// Set up voice based on preferences and locale
|
|
await this.setupVoiceFromPreferences();
|
|
|
|
// Report progress
|
|
if (progressCallback) {
|
|
progressCallback(100, "Kokoro TTS ready");
|
|
}
|
|
|
|
// Dispatch event
|
|
this.dispatchEvent('kokoro-loading-complete', { success: true });
|
|
|
|
this.loading = false;
|
|
return true;
|
|
} catch (directError) {
|
|
console.error('Error initializing Kokoro directly:', directError);
|
|
|
|
// Report progress
|
|
if (progressCallback) {
|
|
progressCallback(100, "Kokoro TTS failed to initialize");
|
|
}
|
|
|
|
// Dispatch event
|
|
this.dispatchEvent('kokoro-loading-complete', { success: false, error: directError });
|
|
|
|
this.loading = false;
|
|
return false;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error initializing Kokoro:', error);
|
|
|
|
// Report progress
|
|
if (progressCallback) {
|
|
progressCallback(100, "Kokoro TTS failed to initialize");
|
|
}
|
|
|
|
// Dispatch event
|
|
this.dispatchEvent('kokoro-loading-complete', { success: false, error });
|
|
|
|
this.loading = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a module from the registry
|
|
* @param {string} moduleId - ID of the module to get
|
|
* @returns {Object|null} - The module or null if not found
|
|
*/
|
|
getModule(moduleId) {
|
|
return moduleRegistry.getModule(moduleId);
|
|
}
|
|
|
|
/**
|
|
* Set up voice based on preferences and locale
|
|
* @returns {Promise<boolean>} - Resolves with success status
|
|
*/
|
|
async setupVoiceFromPreferences() {
|
|
try {
|
|
// Get localization and persistence manager modules
|
|
const localization = this.getModule('localization');
|
|
const persistenceManager = this.getModule('persistence-manager');
|
|
|
|
// Get current locale and preferred voice
|
|
let currentLocale = 'en-us';
|
|
let preferredVoice = '';
|
|
|
|
if (localization) {
|
|
currentLocale = localization.getLocale();
|
|
} else {
|
|
console.warn("Kokoro TTS: Localization module not found");
|
|
}
|
|
|
|
if (persistenceManager) {
|
|
preferredVoice = persistenceManager.getPreference('tts', 'voice', '');
|
|
} else {
|
|
console.warn("Kokoro TTS: Persistence Manager module not found");
|
|
}
|
|
|
|
// If we have a preferred voice, use it
|
|
if (preferredVoice) {
|
|
const success = this.setVoice(preferredVoice);
|
|
if (success) return true;
|
|
}
|
|
|
|
// Otherwise select based on locale
|
|
return this.selectVoiceForLocale(currentLocale);
|
|
} catch (error) {
|
|
console.error("Error setting up voice from preferences:", error);
|
|
return this.selectDefaultVoice();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Select a voice for the given locale
|
|
* @param {string} locale - Locale code
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
selectVoiceForLocale(locale) {
|
|
if (!locale || this.voices.length === 0) {
|
|
return this.selectDefaultVoice();
|
|
}
|
|
|
|
// Normalize locale
|
|
const normalizedLocale = locale.toLowerCase();
|
|
|
|
// Try to find a voice for the exact locale
|
|
let matchingVoice = this.voices.find(voice =>
|
|
voice.lang && voice.lang.toLowerCase() === normalizedLocale
|
|
);
|
|
|
|
// If no exact match, try to find a voice for the language part
|
|
if (!matchingVoice) {
|
|
const langPart = normalizedLocale.split('-')[0];
|
|
matchingVoice = this.voices.find(voice =>
|
|
voice.lang && voice.lang.toLowerCase().startsWith(langPart)
|
|
);
|
|
}
|
|
|
|
// If still no match, use default
|
|
if (!matchingVoice) {
|
|
return this.selectDefaultVoice();
|
|
}
|
|
|
|
// Set the matching voice
|
|
this.currentVoice = matchingVoice;
|
|
console.log(`Kokoro TTS: Selected voice ${matchingVoice.name} for locale ${locale}`);
|
|
|
|
// Update preference
|
|
const persistenceManager = this.getModule('persistence-manager');
|
|
if (persistenceManager) {
|
|
persistenceManager.updatePreference('tts', 'voice', matchingVoice.id);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Select a default voice
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
selectDefaultVoice() {
|
|
if (this.voices.length === 0) {
|
|
console.warn("Kokoro TTS: No voices available for default selection");
|
|
return false;
|
|
}
|
|
|
|
// Prefer English voices if available
|
|
const englishVoice = this.voices.find(voice =>
|
|
voice.lang && voice.lang.toLowerCase().startsWith('en')
|
|
);
|
|
|
|
if (englishVoice) {
|
|
this.currentVoice = englishVoice;
|
|
console.log(`Kokoro TTS: Selected default English voice ${englishVoice.name}`);
|
|
} else {
|
|
// Otherwise use the first available voice
|
|
this.currentVoice = this.voices[0];
|
|
console.log(`Kokoro TTS: Selected first available voice ${this.voices[0].name}`);
|
|
}
|
|
|
|
// Update preference
|
|
const persistenceManager = this.getModule('persistence-manager');
|
|
if (persistenceManager) {
|
|
persistenceManager.updatePreference('tts', 'voice', this.currentVoice.id);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Preload speech for a text
|
|
* @param {string} text - Text to preload
|
|
* @returns {Promise<Object>} - Preloaded audio data
|
|
*/
|
|
async preloadSpeech(text) {
|
|
if (!this.available || !text) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
// Preprocess text
|
|
const processedText = this.preprocessText(text);
|
|
|
|
// Check if already cached
|
|
const cacheKey = `${this.currentVoice?.id || 'default'}_${processedText}`;
|
|
if (this.preloadCache.has(cacheKey)) {
|
|
return this.preloadCache.get(cacheKey);
|
|
}
|
|
|
|
// Generate speech
|
|
const result = await this.generateSpeech(processedText);
|
|
|
|
// Cache result
|
|
this.preloadCache.set(cacheKey, result);
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error("Kokoro: Error preloading speech:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Speak text using preloaded audio
|
|
* @param {Object} preloadData - Preloaded audio data
|
|
* @param {Function} callback - Callback for when speech completes
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
speakPreloaded(preloadData, callback = null) {
|
|
if (!this.available || !preloadData) {
|
|
if (callback) {
|
|
setTimeout(() => callback({ success: false, reason: 'not_available' }), 0);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
// Stop any current speech
|
|
this.stop();
|
|
|
|
// Play the audio
|
|
if (preloadData.play) {
|
|
// Use the play function if available
|
|
preloadData.play();
|
|
|
|
// Call callback after audio duration
|
|
if (callback) {
|
|
setTimeout(() => {
|
|
callback({ success: true });
|
|
}, preloadData.buffer.duration * 1000);
|
|
}
|
|
} else if (preloadData.audio) {
|
|
// Set up event handlers
|
|
preloadData.audio.onended = () => {
|
|
if (callback) {
|
|
callback({ success: true });
|
|
}
|
|
};
|
|
|
|
preloadData.audio.onerror = (error) => {
|
|
console.error("Kokoro: Audio playback error:", error);
|
|
if (callback) {
|
|
callback({ success: false, reason: 'playback_error', error });
|
|
}
|
|
};
|
|
|
|
// Store reference to current audio
|
|
this.currentAudio = preloadData.audio;
|
|
|
|
// Play the audio
|
|
preloadData.audio.play();
|
|
} else {
|
|
console.error("Kokoro: Invalid preload data");
|
|
if (callback) {
|
|
setTimeout(() => callback({ success: false, reason: 'invalid_data' }), 0);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Kokoro: Error playing preloaded speech:", error);
|
|
|
|
if (callback) {
|
|
setTimeout(() => callback({ success: false, reason: 'playback_error', error }), 0);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Speak text
|
|
* @param {string} text - Text to speak
|
|
* @param {Object} options - Speech options
|
|
* @returns {Promise<boolean>} - Resolves with success status
|
|
*/
|
|
async speak(text, options = {}) {
|
|
if (!this.available || !text) return false;
|
|
|
|
try {
|
|
// Stop any current speech
|
|
this.stop();
|
|
|
|
// Preprocess text
|
|
const processedText = this.preprocessText(text);
|
|
|
|
// Generate speech
|
|
const result = await this.generateSpeech(processedText);
|
|
|
|
// Create audio element
|
|
const audio = new Audio();
|
|
|
|
// Set up event handlers
|
|
return new Promise((resolve) => {
|
|
audio.onended = () => {
|
|
this.currentAudio = null;
|
|
resolve(true);
|
|
};
|
|
|
|
audio.onerror = (error) => {
|
|
console.error("Kokoro TTS: Audio playback error:", error);
|
|
this.currentAudio = null;
|
|
resolve(false);
|
|
};
|
|
|
|
// Store reference to current audio
|
|
this.currentAudio = audio;
|
|
|
|
// Play the audio
|
|
if (result.play) {
|
|
// Use the play function if available
|
|
result.play();
|
|
// Resolve after duration
|
|
setTimeout(() => {
|
|
this.currentAudio = null;
|
|
resolve(true);
|
|
}, result.buffer.duration * 1000);
|
|
} else if (result.audio) {
|
|
// Play the audio element
|
|
result.audio.play().catch(error => {
|
|
console.error("Kokoro TTS: Failed to play audio:", error);
|
|
this.currentAudio = null;
|
|
resolve(false);
|
|
});
|
|
} else {
|
|
console.error("Kokoro TTS: Invalid result data");
|
|
this.currentAudio = null;
|
|
resolve(false);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error("Error speaking text with Kokoro TTS:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate speech using the web worker or direct method
|
|
* @param {string} text - Text to generate speech for
|
|
* @returns {Promise<Object>} - Resolves with audio data
|
|
*/
|
|
async generateSpeech(text) {
|
|
if (this.worker) {
|
|
return new Promise((resolve, reject) => {
|
|
this.pendingGeneration = { text, resolve, reject };
|
|
|
|
// Send message to worker to generate speech
|
|
this.worker.postMessage({
|
|
type: 'generate',
|
|
text,
|
|
voice: this.currentVoice?.id || 'en_US-neural',
|
|
speed: this.options.rate
|
|
});
|
|
});
|
|
} else if (this.kokoro) {
|
|
// Fallback to direct method if worker is not available
|
|
return this.kokoro(text, {
|
|
voice: this.currentVoice?.id || 'en_US-neural',
|
|
speed: this.options.rate,
|
|
autoPlay: false
|
|
});
|
|
} else {
|
|
throw new Error('No Kokoro implementation available');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Preprocess text for TTS
|
|
* @param {string} text - Text to preprocess
|
|
* @returns {string} - Processed text
|
|
*/
|
|
preprocessText(text) {
|
|
if (!text) return '';
|
|
|
|
// Trim whitespace
|
|
let processed = text.trim();
|
|
|
|
// Replace multiple spaces with a single space
|
|
processed = processed.replace(/\s+/g, ' ');
|
|
|
|
// Add a period at the end if there's no punctuation
|
|
if (!/[.!?]$/.test(processed)) {
|
|
processed += '.';
|
|
}
|
|
|
|
return processed;
|
|
}
|
|
|
|
/**
|
|
* Stop speaking
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
stop() {
|
|
if (!this.currentAudio) return true;
|
|
|
|
try {
|
|
this.currentAudio.pause();
|
|
this.currentAudio.currentTime = 0;
|
|
this.currentAudio = null;
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Kokoro: Error stopping speech:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pause speaking
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
pause() {
|
|
if (!this.currentAudio) return false;
|
|
|
|
try {
|
|
this.currentAudio.pause();
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Kokoro: Error pausing speech:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resume speaking
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
resume() {
|
|
if (!this.currentAudio) return false;
|
|
|
|
try {
|
|
this.currentAudio.play();
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Kokoro: Error resuming speech:", error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if TTS is available
|
|
* @returns {boolean} - True if TTS is available
|
|
*/
|
|
isAvailable() {
|
|
return this.available;
|
|
}
|
|
|
|
/**
|
|
* Get handler ID
|
|
* @returns {string} - Handler ID
|
|
*/
|
|
getId() {
|
|
return this.id;
|
|
}
|
|
|
|
/**
|
|
* Get available voices
|
|
* @returns {Array} - Array of voice objects
|
|
*/
|
|
getVoices() {
|
|
return this.voices.map(voice => ({
|
|
id: voice.id,
|
|
name: voice.name,
|
|
lang: voice.lang
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Set the voice by ID or name
|
|
* @param {string} voiceId - Voice ID or name
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
setVoice(voiceId) {
|
|
if (!voiceId) return false;
|
|
|
|
const voice = this.voices.find(v => v.id === voiceId || v.name === voiceId);
|
|
if (!voice) {
|
|
console.warn(`Kokoro TTS: Voice '${voiceId}' not found`);
|
|
return false;
|
|
}
|
|
|
|
this.currentVoice = voice;
|
|
console.log(`Kokoro TTS: Set voice to ${voice.name}`);
|
|
|
|
// Update preference
|
|
const persistenceManager = this.getModule('persistence-manager');
|
|
if (persistenceManager) {
|
|
persistenceManager.updatePreference('tts', 'voice', voice.id);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Set voice options
|
|
* @param {Object} options - Voice options
|
|
* @returns {boolean} - Success status
|
|
*/
|
|
setOptions(options = {}) {
|
|
if (!options) return false;
|
|
|
|
// Update options
|
|
if (typeof options.volume === 'number') this.options.volume = options.volume;
|
|
if (typeof options.rate === 'number') {
|
|
// Clamp rate between 0.5 and 2.0
|
|
this.options.rate = Math.max(0.5, Math.min(2.0, options.rate));
|
|
}
|
|
if (typeof options.pitch === 'number') this.options.pitch = options.pitch;
|
|
|
|
// Update preferences
|
|
const persistenceManager = this.getModule('persistence-manager');
|
|
if (persistenceManager) {
|
|
if (typeof options.volume === 'number') {
|
|
persistenceManager.updatePreference('tts', 'volume', options.volume);
|
|
}
|
|
if (typeof options.rate === 'number') {
|
|
persistenceManager.updatePreference('tts', 'rate', options.rate);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
} |