speech-provider
Version:
A unified interface for browser speech synthesis and Eleven Labs voices
193 lines • 7.3 kB
JavaScript
/**
* A voice provider that uses the browser's built-in speech synthesis.
* This provider is available in all modern browsers and doesn't require any API keys.
*/
export class BrowserVoiceProvider {
name = "Browser";
voicesInitialized = false;
voicesReadyPromise = null;
/**
* Get available voices for a given language code.
* @param options - The options for getting voices
* @param options.lang - The language code to match (e.g., "en-US")
* @param options.minVoices - The minimum number of voices to return
* @returns A promise that resolves to an array of browser voices
*/
async getVoices({ lang, minVoices, }) {
// Ensure voices are loaded before trying to filter them
await this.ensureVoicesLoaded();
const filteredVoices = this.getBrowserVoicesForLanguage(lang, minVoices);
return filteredVoices.map((voice) => new BrowserSpeechSynthesisVoice(voice, voice.lang, this));
}
/**
* Ensures that the browser's speech synthesis voices are loaded.
* In some browsers, especially Chrome, voices are loaded asynchronously.
*/
async ensureVoicesLoaded() {
if (this.voicesInitialized) {
return;
}
if (typeof window === "undefined" || !window.speechSynthesis) {
this.voicesInitialized = true;
return;
}
// Check if voices are already available
const voices = window.speechSynthesis.getVoices();
if (voices.length > 0) {
this.voicesInitialized = true;
return;
}
// If voices aren't available yet, wait for them to load
if (!this.voicesReadyPromise) {
this.voicesReadyPromise = new Promise((resolve) => {
// Some browsers (especially Chrome) load voices asynchronously
if ("onvoiceschanged" in window.speechSynthesis) {
const handleVoicesChanged = () => {
this.voicesInitialized = true;
window.speechSynthesis.removeEventListener("voiceschanged", handleVoicesChanged);
resolve();
};
window.speechSynthesis.addEventListener("voiceschanged", handleVoicesChanged);
// Add a timeout to avoid hanging forever
setTimeout(() => {
if (!this.voicesInitialized) {
this.voicesInitialized = true;
window.speechSynthesis.removeEventListener("voiceschanged", handleVoicesChanged);
resolve();
}
}, 2000);
}
else {
this.voicesInitialized = true;
resolve();
}
});
}
return this.voicesReadyPromise;
}
/**
* Get the default voice for a given language code.
* @param options - The options for getting the default voice
* @param options.lang - The language code to match (e.g., "en-US")
* @returns A promise that resolves to the default voice or null if none is available
*/
async getDefaultVoice({ lang, }) {
const voices = await this.getVoices({ lang, minVoices: 1 });
const defaultVoice = voices.find((voice) => voice.isDefault);
if (defaultVoice) {
return defaultVoice;
}
return voices?.[0] ?? null;
}
/**
* Get browser voices for a given language code.
* @param lang - The language code to match
* @param minVoices - The minimum number of voices to return
* @returns An array of browser voices
* @private
*/
getBrowserVoicesForLanguage(lang, minVoices) {
if (typeof window === "undefined" || !window.speechSynthesis) {
return [];
}
const allVoices = window.speechSynthesis.getVoices();
// Try exact match first
const exactMatch = allVoices.filter((voice) => voice.lang === lang);
if (exactMatch.length >= minVoices) {
return exactMatch;
}
// If the language has a region code (contains '-'), try matching just the language part
if (lang.includes("-")) {
const baseLanguage = lang.replace(/-.+/, "");
const baseMatches = allVoices.filter((voice) => voice.lang.replace(/-.+/, "") === baseLanguage);
if (baseMatches.length >= minVoices) {
return baseMatches;
}
}
// Fall back to prefix match using first two characters
const languageMatch = allVoices.filter((voice) => voice.lang.startsWith(lang.slice(0, 2)));
if (languageMatch.length >= minVoices) {
return languageMatch;
}
return allVoices;
}
}
/**
* A voice implementation that wraps the browser's SpeechSynthesisVoice.
*/
export class BrowserSpeechSynthesisVoice {
lang;
provider;
voice;
constructor(voice, lang, provider) {
this.lang = lang;
this.provider = provider;
this.voice = voice;
this.lang = lang;
}
/** Whether this is the default voice for its language */
get isDefault() {
return this.voice.default;
}
/** The display name of the voice */
get name() {
return this.voice.name.replace(/ (.+)$/, "");
}
/** The unique identifier for the voice */
get id() {
return this.voice.voiceURI;
}
/** The description of the voice (e.g., "English (US)") */
get description() {
const match = / (.+)$/.exec(this.voice.name);
return match?.[1].replace(/\(Chinese \((.+?)\)\)/, "$1") ?? null;
}
/**
* Create a new utterance with this voice.
* @param text - The text to speak
* @returns A new utterance that can be started and stopped
*/
createUtterance(text) {
return new BrowserSpeechSynthesisUtterance(this.voice, text, this.lang);
}
}
/**
* An utterance implementation that wraps the browser's SpeechSynthesisUtterance.
*/
class BrowserSpeechSynthesisUtterance {
voice;
text;
lang;
utterance;
constructor(voice, text, lang) {
this.voice = voice;
this.text = text;
this.lang = lang;
this.utterance = new SpeechSynthesisUtterance(text);
this.utterance.lang = lang;
this.utterance.voice = voice;
}
/** Start speaking the utterance */
start() {
if (typeof window !== "undefined" && window.speechSynthesis) {
window.speechSynthesis.speak(this.utterance);
}
}
/** Stop speaking the utterance */
stop() {
if (typeof window !== "undefined" && window.speechSynthesis) {
window.speechSynthesis.cancel();
}
}
/** Set the callback for when the utterance starts speaking */
set onstart(callback) {
this.utterance.onstart = callback;
}
/** Set the callback for when the utterance finishes speaking */
set onend(callback) {
this.utterance.onend = callback;
}
}
/** The default browser voice provider instance */
export const browserVoiceProvider = new BrowserVoiceProvider();
//# sourceMappingURL=BrowserVoiceProvider.js.map