From 41bc5e7eb526e2a0e7922ae3c1e1c724fc9f42a5 Mon Sep 17 00:00:00 2001 From: ztimson Date: Sun, 18 Jan 2026 13:37:55 -0500 Subject: [PATCH] Fixed TTS incorrect voice race condition, errors on stop and speakStream done response --- package.json | 2 +- src/tts.ts | 42 ++++++++++++++++++++++++++++++++---------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index a70f031..9eef052 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.28.9", + "version": "0.28.10", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/tts.ts b/src/tts.ts index a99d4de..e3c924e 100644 --- a/src/tts.ts +++ b/src/tts.ts @@ -4,6 +4,8 @@ export class TTS { private static readonly QUALITY_PATTERNS = ['Google', 'Microsoft', 'Samantha', 'Premium', 'Natural', 'Neural']; private _currentUtterance: SpeechSynthesisUtterance | null = null; + private _voicesLoaded: Promise; + private _isStopping: boolean = false; private _rate: number = 1.0; get rate(): number { return this._rate; } @@ -35,6 +37,7 @@ export class TTS { /** Create a TTS instance with optional configuration */ constructor(config?: {rate?: number; pitch?: number; volume?: number; voice?: SpeechSynthesisVoice | null}) { + this._voicesLoaded = this.initializeVoices(); if(config) { if(config.rate !== undefined) this._rate = config.rate; 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 { + 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 * @param lang Speaking language @@ -77,30 +98,30 @@ export class TTS { } /** Speaks text and returns a Promise which resolves once complete */ - speak(text: string): Promise { + async speak(text: string): Promise { if(!text.trim()) return Promise.resolve(); - + await this._voicesLoaded; return new Promise((resolve, reject) => { this._currentUtterance = this.createUtterance(text); - this._currentUtterance.onend = () => { this._currentUtterance = null; resolve(); }; - this._currentUtterance.onerror = (error) => { this._currentUtterance = null; - reject(error); + if(this._isStopping && error.error === 'interrupted') resolve(); + else reject(error); }; - window.speechSynthesis.speak(this._currentUtterance); }); } /** Stops all TTS */ stop(): void { + this._isStopping = true; window.speechSynthesis.cancel(); 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 */ - speakStream(): {next: (text: string) => void, done: () => void} { + speakStream(): {next: (text: string) => void, done: () => Promise} { let buffer = ''; + let streamPromise: Promise = Promise.resolve(); const sentenceRegex = /[^.!?\n]+[.!?\n]+/g; - return { next: (text: string): void => { buffer += text; const sentences = buffer.match(sentenceRegex); if(sentences) { - sentences.forEach(sentence => this.speak(sentence.trim())); + sentences.forEach(sentence => streamPromise = this.speak(sentence.trim())); buffer = buffer.replace(sentenceRegex, ''); } }, done: async (): Promise => { if(buffer.trim()) { - await this.speak(buffer.trim()); + streamPromise = this.speak(buffer.trim()); buffer = ''; } + await streamPromise; } }; }