Compare commits

..

1 Commits

Author SHA1 Message Date
41bc5e7eb5 Fixed TTS incorrect voice race condition, errors on stop and speakStream done response
All checks were successful
Build / Publish Docs (push) Successful in 36s
Build / Build NPM Project (push) Successful in 47s
Build / Tag Version (push) Successful in 6s
2026-01-18 13:37:55 -05:00
2 changed files with 33 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/utils", "name": "@ztimson/utils",
"version": "0.28.9", "version": "0.28.10",
"description": "Utility library", "description": "Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",

View File

@@ -4,6 +4,8 @@ export class TTS {
private static readonly QUALITY_PATTERNS = ['Google', 'Microsoft', 'Samantha', 'Premium', 'Natural', 'Neural']; private static readonly QUALITY_PATTERNS = ['Google', 'Microsoft', 'Samantha', 'Premium', 'Natural', 'Neural'];
private _currentUtterance: SpeechSynthesisUtterance | null = null; private _currentUtterance: SpeechSynthesisUtterance | null = null;
private _voicesLoaded: Promise<void>;
private _isStopping: boolean = false;
private _rate: number = 1.0; private _rate: number = 1.0;
get rate(): number { return this._rate; } get rate(): number { return this._rate; }
@@ -35,6 +37,7 @@ export class TTS {
/** Create a TTS instance with optional configuration */ /** Create a TTS instance with optional configuration */
constructor(config?: {rate?: number; pitch?: number; volume?: number; voice?: SpeechSynthesisVoice | null}) { constructor(config?: {rate?: number; pitch?: number; volume?: number; voice?: SpeechSynthesisVoice | null}) {
this._voicesLoaded = this.initializeVoices();
if(config) { if(config) {
if(config.rate !== undefined) this._rate = config.rate; if(config.rate !== undefined) this._rate = config.rate;
if(config.pitch !== undefined) this._pitch = config.pitch; if(config.pitch !== undefined) this._pitch = config.pitch;
@@ -43,6 +46,24 @@ export class TTS {
} }
} }
/** Initializes voice loading and sets default voice if needed */
private initializeVoices(): Promise<void> {
return new Promise((resolve) => {
const voices = window.speechSynthesis.getVoices();
if(voices.length > 0) {
if(!this._voice) this._voice = TTS.bestVoice();
resolve();
} else {
const handler = () => {
window.speechSynthesis.removeEventListener('voiceschanged', handler);
if(!this._voice) this._voice = TTS.bestVoice();
resolve();
};
window.speechSynthesis.addEventListener('voiceschanged', handler);
}
});
}
/** /**
* Selects the best available TTS voice, prioritizing high-quality options * Selects the best available TTS voice, prioritizing high-quality options
* @param lang Speaking language * @param lang Speaking language
@@ -77,30 +98,30 @@ export class TTS {
} }
/** Speaks text and returns a Promise which resolves once complete */ /** Speaks text and returns a Promise which resolves once complete */
speak(text: string): Promise<void> { async speak(text: string): Promise<void> {
if(!text.trim()) return Promise.resolve(); if(!text.trim()) return Promise.resolve();
await this._voicesLoaded;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this._currentUtterance = this.createUtterance(text); this._currentUtterance = this.createUtterance(text);
this._currentUtterance.onend = () => { this._currentUtterance.onend = () => {
this._currentUtterance = null; this._currentUtterance = null;
resolve(); resolve();
}; };
this._currentUtterance.onerror = (error) => { this._currentUtterance.onerror = (error) => {
this._currentUtterance = null; this._currentUtterance = null;
reject(error); if(this._isStopping && error.error === 'interrupted') resolve();
else reject(error);
}; };
window.speechSynthesis.speak(this._currentUtterance); window.speechSynthesis.speak(this._currentUtterance);
}); });
} }
/** Stops all TTS */ /** Stops all TTS */
stop(): void { stop(): void {
this._isStopping = true;
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
this._currentUtterance = null; this._currentUtterance = null;
setTimeout(() => { this._isStopping = false; }, 0);
} }
/** /**
@@ -115,24 +136,25 @@ export class TTS {
* *
* @returns Object with next function for passing chunk of streamed text and done for completing the stream * @returns Object with next function for passing chunk of streamed text and done for completing the stream
*/ */
speakStream(): {next: (text: string) => void, done: () => void} { speakStream(): {next: (text: string) => void, done: () => Promise<void>} {
let buffer = ''; let buffer = '';
let streamPromise: Promise<void> = Promise.resolve();
const sentenceRegex = /[^.!?\n]+[.!?\n]+/g; const sentenceRegex = /[^.!?\n]+[.!?\n]+/g;
return { return {
next: (text: string): void => { next: (text: string): void => {
buffer += text; buffer += text;
const sentences = buffer.match(sentenceRegex); const sentences = buffer.match(sentenceRegex);
if(sentences) { if(sentences) {
sentences.forEach(sentence => this.speak(sentence.trim())); sentences.forEach(sentence => streamPromise = this.speak(sentence.trim()));
buffer = buffer.replace(sentenceRegex, ''); buffer = buffer.replace(sentenceRegex, '');
} }
}, },
done: async (): Promise<void> => { done: async (): Promise<void> => {
if(buffer.trim()) { if(buffer.trim()) {
await this.speak(buffer.trim()); streamPromise = this.speak(buffer.trim());
buffer = ''; buffer = '';
} }
await streamPromise;
} }
}; };
} }