Repository files navigation

tts-queue

TTS audio streaming manager with sentence-boundary queuing, gapless playback, and fast interruption handling.

npm versionnpm downloadslicensenodetypes


Description

tts-queue is the orchestration layer between text that needs to be spoken and audio that the user hears. It accepts text -- either as complete strings or as streaming token sequences from an LLM -- splits it into sentence-sized segments, sends each segment to a pluggable TTS provider for audio synthesis, and plays the resulting audio back-to-back through a pluggable audio sink.

The package handles every concern that sits between those two endpoints: sentence boundary detection (abbreviations, decimals, ellipsis, URLs), ordered FIFO playback queuing, per-segment lifecycle state management, cancellation of in-flight synthesis calls via AbortController, pause/resume, and error recovery. It replaces the 200-400 lines of async queue management, timer coordination, and provider-specific plumbing that every voice AI application otherwise implements from scratch.

Zero runtime dependencies. TypeScript-first. Provider-agnostic.


Installation

npm install tts-queue

Requires Node.js 18 or later.


Quick Start

import{createQueue}from'tts-queue';importtype{TTSProvider,AudioSink,AudioData,SegmentInfo}from'tts-queue';// 1. Define a TTS provider (wrap any TTS SDK)constprovider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaityourTTSClient.synthesize(text);return{buffer: response.audioBuffer,format: 'mp3',sizeBytes: response.audioBuffer.length,durationMs: response.durationMs,};},};// 2. Define an audio sink (where audio goes)constsink: AudioSink={asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{awaitspeaker.play(audio.buffer);},};// 3. Create the queue and push textconstqueue=createQueue({ provider, sink });awaitqueue.push('Hello there. How are you doing today? The weather is nice.');// Sentences are split, synthesized, and played in order automatically.// 4. Wait for all segments to finishawaitqueue.drain();// 5. Clean upawaitqueue.close();

Features

  • Sentence-boundary splitting -- Automatically segments text at sentence boundaries with handling for abbreviations (Dr., Mr., Mrs., etc.), decimal numbers, ellipsis, and URLs/domains.
  • Clause-boundary fallback -- Long sentences exceeding maxChars are split at semicolons, em dashes, and commas.
  • Short segment filtering -- Segments shorter than minChars are filtered to prevent choppy single-word synthesis.
  • Provider-agnostic -- Works with any TTS provider through a simple TTSProvider interface. Swap providers without rewriting queue logic.
  • Pluggable audio sinks -- Route audio to speakers, files, WebSockets, or test buffers via the AudioSink interface.
  • Ordered FIFO playback -- Segments play in strict sequential order.
  • Pause / Resume -- Suspend and resume playback without losing queue position. Calls through to sink pause() / resume() when available.
  • Fast interruption -- cancel() aborts all in-flight synthesis calls via AbortController and transitions the queue to idle.
  • Priority insertion -- pushImmediate() cancels pending segments and inserts new text at the front of the queue.
  • Per-segment lifecycle -- Each segment progresses through pending, synthesizing, synthesized, playing, played (or failed / cancelled) with timestamp tracking at every transition.
  • Typed events -- Subscribe to segment:start, segment:end, segment:error, queue:empty, queue:drain, and state:change events with full TypeScript typing.
  • Callback hooks -- Optional onSegmentStart, onSegmentEnd, onSegmentError, and onQueueEmpty callbacks on QueueOptions.
  • Queue statistics -- getStats() returns total, completed, failed, cancelled, and pending segment counts, total duration, and total characters processed.
  • Custom splitting -- Provide a custom splitting.custom function to override the built-in sentence splitter.
  • Zero runtime dependencies -- Only dev dependencies for TypeScript, ESLint, and Vitest.

API Reference

createQueue(options: QueueOptions): TTSQueue

Factory function that creates and returns a TTSQueue instance.

import{createQueue}from'tts-queue';constqueue=createQueue({
provider,// Required: TTSProvider
sink,// Required: AudioSinksplitting: {// Optional: splitting configurationmaxChars: 200,minChars: 10,on: 'sentence',custom: (text)=>text.split('\n'),},concurrency: 1,// Optional: concurrent synthesis limitprefetchCount: 2,// Optional: segments to pre-fetchonSegmentStart: (segment)=>{/* ... */},onSegmentEnd: (segment)=>{/* ... */},onSegmentError: (error,segment)=>{/* ... */},onQueueEmpty: ()=>{/* ... */},});

TTSQueue

The queue instance returned by createQueue. Implements the TTSQueue interface.

queue.push(text: string): Promise<SegmentInfo[]>

Split text into sentences and append segments to the queue. Returns the created SegmentInfo objects. Processing begins immediately.

constsegments=awaitqueue.push('First sentence. Second sentence. Third sentence.');console.log(segments.length);// 3

queue.pushImmediate(text: string): Promise<SegmentInfo[]>

Cancel all pending and in-progress segments, then insert new text at the front of the queue. Use this for interruption-and-replace patterns (e.g., the user asks a new question while the previous answer is still playing).

constsegments=awaitqueue.pushImmediate('Interrupting with new content.');

queue.pause(): Promise<void>

Pause playback. The queue transitions to paused state. If the sink implements pause(), it is called.

awaitqueue.pause();console.log(queue.getState());// 'paused'

queue.resume(): Promise<void>

Resume playback after a pause. The queue transitions back to playing state. If the sink implements resume(), it is called. Segment processing resumes automatically.

awaitqueue.resume();console.log(queue.getState());// 'playing'

queue.cancel(ids?: string[]): Promise<CancelResult>

Cancel segments. When called with no arguments, cancels all non-terminal segments, aborts in-flight synthesis via AbortController, and resets the queue to idle. When called with specific segment IDs, cancels only those segments.

// Cancel everythingconstresult=awaitqueue.cancel();console.log(result.cancelled);// number of segments cancelledconsole.log(result.ids);// IDs of cancelled segments// Cancel specific segmentsconstresult2=awaitqueue.cancel(['segment-id-1','segment-id-2']);

queue.drain(): Promise<void>

Wait for all segments to finish processing. Transitions the queue to draining state while segments complete, then resolves when the queue reaches idle.

awaitqueue.push('Some text to speak.');awaitqueue.drain();// resolves when all audio has finished playing

queue.close(): Promise<void>

Cancel all activity and permanently close the queue. Transitions to closed state. No more pushes are accepted after this call.

awaitqueue.close();console.log(queue.getState());// 'closed'

queue.getState(): QueueState

Returns the current queue state: 'idle', 'playing', 'paused', 'draining', or 'closed'.

queue.getStats(): QueueStats

Returns cumulative statistics for all segments processed by the queue.

conststats=queue.getStats();// {// totalSegments: 5,// completedSegments: 3,// failedSegments: 0,// cancelledSegments: 2,// pendingSegments: 0,// totalDurationMs: 4500,// totalChars: 312,// }

queue.getSegments(): SegmentInfo[]

Returns a snapshot (copy) of all segments and their current state.

queue.on<K>(event: K, listener: TTSQueueEvents[K]): void

Subscribe to a typed queue event.

queue.off<K>(event: K, listener: TTSQueueEvents[K]): void

Unsubscribe from a typed queue event.


Events

EventPayloadDescription
segment:startSegmentInfoFired when synthesis begins for a segment
segment:endSegmentInfoFired when a segment finishes playing
segment:errorTTSQueueError, SegmentInfoFired on synthesis or playback error
queue:empty--Fired when all segments have been played
queue:drain--Fired when the queue has been drained
state:changeQueueStateFired on every queue state transition

splitSentences(text: string, options?: SplitOptions): string[]

Standalone sentence splitter. Used internally by createQueue, but also exported for direct use.

import{splitSentences}from'tts-queue';splitSentences('Dr. Smith went to the store. She bought apples.');// ['Dr. Smith went to the store.', 'She bought apples.']splitSentences('Hello. World.',{minLength: 1});// ['Hello.', 'World.']splitSentences('');// []

SplitOptions

PropertyTypeDefaultDescription
minLengthnumber10Minimum segment length in characters. Shorter segments are filtered out.
maxLengthnumber200Maximum segment length. Longer segments are split at clause boundaries.
preserveWhitespacebooleanfalseWhen true, preserves leading/trailing whitespace in segments.

The built-in splitter handles:

  • Abbreviations: Mr., Mrs., Ms., Dr., Prof., St., Jr., Sr., vs., etc., e.g., i.e., Fig., Approx., Dept., Est., Govt., Inc., Corp., Ltd., Co., U.S., U.K., U.N.
  • Decimal numbers: 98.6, 3.14, $9.99 -- periods between digits are not treated as boundaries.
  • Ellipsis: ... -- consecutive periods are not treated as boundaries.
  • URLs and domains: example.com -- periods followed immediately by a letter or digit (no space) are not treated as boundaries.
  • Single-letter initials: A. B. Smith -- single uppercase letters followed by a period are not treated as boundaries.
  • Quoted strings: Sentence boundaries inside quoted strings (double quotes, smart quotes) are ignored.
  • Long sentence fallback: Sentences exceeding maxLength are split at semicolons, em dashes (---, unicode em dash), then commas (only when both halves meet minLength).

createSegment(text: string, index: number): SegmentInfo

Create a new segment in pending state with a unique UUID, timestamp, and the given text and index.

import{createSegment}from'tts-queue';constsegment=createSegment('Hello world.',0);// { id: 'uuid', text: 'Hello world.', index: 0, state: 'pending', addedAt: Date }

transitionSegment(segment: SegmentInfo, newState: SegmentState, extra?: Partial<SegmentInfo>): SegmentInfo

Immutably transition a segment to a new state. Enforces the valid state transition graph and sets appropriate timestamps (synthesisStartedAt, synthesisCompletedAt, playbackStartedAt, playbackCompletedAt). Throws TTSQueueError on invalid transitions. The original segment object is never mutated.

Valid transitions:

pending -> synthesizing | cancelled
synthesizing -> synthesized | failed | cancelled
synthesized -> playing | cancelled
playing -> played | failed | cancelled
played -> (terminal)
failed -> (terminal)
cancelled -> (terminal)

TTSQueueError

Custom error class for all errors originating from the queue. Extends Error with additional context fields.

import{TTSQueueError}from'tts-queue';try{// ...}catch(err){if(errinstanceofTTSQueueError){console.log(err.name);// 'TTSQueueError'console.log(err.stage);// 'synthesis' | 'playback' | 'splitting' | 'internal'console.log(err.cause);// underlying Error, if anyconsole.log(err.segment);// SegmentInfo, if associated with a segment}}

Error Factory Functions

FunctionStageDescription
synthErrorsynthesisTTS provider synthesis failure
playbackErrorplaybackAudio sink playback failure
splittingErrorsplittingText splitting failure
internalErrorinternalQueue internal error (invalid state)

Each factory accepts (message: string, cause?: Error, segment?: SegmentInfo) and returns a TTSQueueError.

import{synthError}from'tts-queue';consterr=synthError('Provider timeout',newError('ETIMEDOUT'),segmentInfo);

Configuration

QueueOptions

PropertyTypeRequiredDefaultDescription
providerTTSProviderYes--TTS synthesis provider
sinkAudioSinkYes--Audio output destination
splittingSplittingOptionsNo{}Sentence splitting configuration
concurrencynumberNo1Maximum concurrent synthesis calls
prefetchCountnumberNo2Number of segments to pre-fetch ahead of playback
onSegmentStart(segment: SegmentInfo) => voidNo--Callback when a segment begins synthesis
onSegmentEnd(segment: SegmentInfo) => voidNo--Callback when a segment finishes playback
onSegmentError(error: TTSQueueError, segment) => voidNo--Callback on segment-level errors
onQueueEmpty() => voidNo--Callback when all segments have completed

SplittingOptions

PropertyTypeDefaultDescription
maxCharsnumber200Maximum segment length before clause-boundary split
minCharsnumber10Minimum segment length; shorter segments are filtered
on'sentence' | 'word' | 'paragraph''sentence'Splitting strategy hint
custom(text: string) => string[]--Custom splitter function; overrides built-in logic

TTSProvider Interface

interfaceTTSProvider{synthesize(text: string,options?: SynthesisOptions): Promise<AudioData>;synthesizeStream?(text: string,options?: SynthesisOptions): AsyncIterable<AudioChunk>;}
MethodRequiredDescription
synthesizeYesSynthesize text into a complete audio buffer
synthesizeStreamNoSynthesize text as a stream of audio chunks

SynthesisOptions

PropertyTypeDescription
voicestringVoice identifier for the TTS engine
speednumberPlayback speed multiplier
formatAudioFormatDesired output audio format
sampleRatenumberDesired output sample rate in Hz

AudioSink Interface

interfaceAudioSink{play(audio: AudioData,segment: SegmentInfo): Promise<void>;pause?(): Promise<void>;resume?(): Promise<void>;stop?(): Promise<void>;}
MethodRequiredDescription
playYesPlay audio data for a given segment
pauseNoPause current playback
resumeNoResume paused playback
stopNoImmediately stop all playback

AudioData

PropertyTypeRequiredDescription
bufferBufferYesRaw audio bytes
formatAudioFormatYesAudio codec: 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'
sizeBytesnumberYesSize of the audio buffer in bytes
sampleRatenumberNoSample rate in Hz
channelsnumberNoNumber of audio channels
durationMsnumberNoDuration of the audio in milliseconds

SegmentInfo

PropertyTypeDescription
idstringUnique UUID for the segment
textstringSource text for the segment
indexnumber0-based position in the queue
stateSegmentStateCurrent lifecycle state
addedAtDateTimestamp when the segment was created
synthesisStartedAtDateTimestamp when synthesis began (optional)
synthesisCompletedAtDateTimestamp when synthesis completed (optional)
playbackStartedAtDateTimestamp when playback began (optional)
playbackCompletedAtDateTimestamp when playback completed (optional)
durationMsnumberAudio duration in milliseconds (optional)
errorErrorError that caused failure (optional)

Error Handling

All errors emitted by the queue are instances of TTSQueueError with a stage property indicating where the error originated.

Synthesis Errors

When a TTS provider's synthesize() call throws or rejects, the segment transitions to failed, a segment:error event is emitted with stage: 'synthesis', and the queue advances to the next segment. The queue does not stop.

queue.on('segment:error',(error,segment)=>{if(error.stage==='synthesis'){console.error(`Synthesis failed for "${segment.text}":`,error.cause);}});

Playback Errors

When the audio sink's play() method throws, the segment transitions to failed, a segment:error event is emitted with stage: 'playback', and the queue advances to the next segment.

Cancellation Errors

If a synthesis call is aborted via cancel(), the AbortController signal fires and the segment transitions to cancelled without emitting an error event. This is the expected path for interruptions.

Closed Queue

Calling push() or pushImmediate() on a closed queue throws a TTSQueueError with stage: 'internal' and the message "Queue is closed".

Invalid State Transitions

Attempting an invalid segment state transition (e.g., pending to played) throws a TTSQueueError with stage: 'internal'. This guards against programming errors in provider or sink implementations.


Advanced Usage

Priority Interruption

Replace the current playback with new content immediately:

// User asks a new question while the previous answer is playingawaitqueue.pushImmediate('Here is the answer to your new question.');// All pending/in-progress segments are cancelled, new text takes priority

Selective Cancellation

Cancel specific segments by ID while allowing others to continue:

constsegments=awaitqueue.push('Sentence one. Sentence two. Sentence three.');// Cancel only the last segmentawaitqueue.cancel([segments[2].id]);

Custom Sentence Splitting

Override the built-in splitter with your own logic:

constqueue=createQueue({
provider,
sink,splitting: {custom: (text)=>text.split(/\n\n/),// Split on double newlines},});

Event-Driven Progress Tracking

queue.on('segment:start',(segment)=>{console.log(`Synthesizing: "${segment.text}" (segment ${segment.index})`);});queue.on('segment:end',(segment)=>{console.log(`Finished: "${segment.text}" (${segment.durationMs}ms)`);});queue.on('state:change',(state)=>{console.log(`Queue state: ${state}`);});queue.on('queue:empty',()=>{console.log('All segments processed');});

Monitoring with Callbacks

constqueue=createQueue({
provider,
sink,onSegmentStart: (seg)=>metrics.trackSynthesisStart(seg.id),onSegmentEnd: (seg)=>metrics.trackSynthesisEnd(seg.id,seg.durationMs),onSegmentError: (err,seg)=>logger.error({ err,segmentId: seg.id}),onQueueEmpty: ()=>logger.info('Queue drained'),});

OpenAI TTS Provider Example

importOpenAIfrom'openai';importtype{TTSProvider,AudioData}from'tts-queue';constopenai=newOpenAI();constopenaiProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitopenai.audio.speech.create({model: 'tts-1',voice: 'alloy',input: text,response_format: 'mp3',});constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

ElevenLabs TTS Provider Example

importtype{TTSProvider,AudioData}from'tts-queue';constelevenLabsProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitfetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,{method: 'POST',headers: {'xi-api-key': process.env.ELEVENLABS_API_KEY!,'Content-Type': 'application/json',},body: JSON.stringify({ text,model_id: 'eleven_monolingual_v1'}),},);constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

Test Buffer Sink

Collect all audio data in memory for assertions:

importtype{AudioSink,AudioData,SegmentInfo}from'tts-queue';functioncreateTestSink(): AudioSink&{played: SegmentInfo[]}{constplayed: SegmentInfo[]=[];return{
played,asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{played.push(segment);},};}

TypeScript

tts-queue is written in TypeScript and ships with full type declarations (dist/index.d.ts). All exports are fully typed.

Exported Types

importtype{// Core interfacesTTSQueue,TTSProvider,AudioSink,QueueOptions,// Data typesAudioData,AudioChunk,AudioFormat,// 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'SynthesisOptions,// Segment typesSegmentInfo,SegmentState,// 'pending' | 'synthesizing' | 'synthesized' | 'playing'// | 'played' | 'cancelled' | 'failed'// Queue typesQueueState,// 'idle' | 'playing' | 'paused' | 'draining' | 'closed'QueueStats,CancelResult,TTSQueueEvents,// SplittingSplittingOptions,SplitOptions,// ErrorsTTSQueueStage,// 'synthesis' | 'playback' | 'splitting' | 'internal'}from'tts-queue';

Exported Values

import{createQueue,splitSentences,createSegment,transitionSegment,TTSQueueError,synthError,playbackError,splittingError,internalError,}from'tts-queue';

License

MIT

About

TTS audio streaming manager with sentence-boundary queuing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

tts-queue

TTS audio streaming manager with sentence-boundary queuing, gapless playback, and fast interruption handling.

npm versionnpm downloadslicensenodetypes


Description

tts-queue is the orchestration layer between text that needs to be spoken and audio that the user hears. It accepts text -- either as complete strings or as streaming token sequences from an LLM -- splits it into sentence-sized segments, sends each segment to a pluggable TTS provider for audio synthesis, and plays the resulting audio back-to-back through a pluggable audio sink.

The package handles every concern that sits between those two endpoints: sentence boundary detection (abbreviations, decimals, ellipsis, URLs), ordered FIFO playback queuing, per-segment lifecycle state management, cancellation of in-flight synthesis calls via AbortController, pause/resume, and error recovery. It replaces the 200-400 lines of async queue management, timer coordination, and provider-specific plumbing that every voice AI application otherwise implements from scratch.

Zero runtime dependencies. TypeScript-first. Provider-agnostic.


Installation

npm install tts-queue

Requires Node.js 18 or later.


Quick Start

import{createQueue}from'tts-queue';importtype{TTSProvider,AudioSink,AudioData,SegmentInfo}from'tts-queue';// 1. Define a TTS provider (wrap any TTS SDK)constprovider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaityourTTSClient.synthesize(text);return{buffer: response.audioBuffer,format: 'mp3',sizeBytes: response.audioBuffer.length,durationMs: response.durationMs,};},};// 2. Define an audio sink (where audio goes)constsink: AudioSink={asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{awaitspeaker.play(audio.buffer);},};// 3. Create the queue and push textconstqueue=createQueue({ provider, sink });awaitqueue.push('Hello there. How are you doing today? The weather is nice.');// Sentences are split, synthesized, and played in order automatically.// 4. Wait for all segments to finishawaitqueue.drain();// 5. Clean upawaitqueue.close();

Features

  • Sentence-boundary splitting -- Automatically segments text at sentence boundaries with handling for abbreviations (Dr., Mr., Mrs., etc.), decimal numbers, ellipsis, and URLs/domains.
  • Clause-boundary fallback -- Long sentences exceeding maxChars are split at semicolons, em dashes, and commas.
  • Short segment filtering -- Segments shorter than minChars are filtered to prevent choppy single-word synthesis.
  • Provider-agnostic -- Works with any TTS provider through a simple TTSProvider interface. Swap providers without rewriting queue logic.
  • Pluggable audio sinks -- Route audio to speakers, files, WebSockets, or test buffers via the AudioSink interface.
  • Ordered FIFO playback -- Segments play in strict sequential order.
  • Pause / Resume -- Suspend and resume playback without losing queue position. Calls through to sink pause() / resume() when available.
  • Fast interruption -- cancel() aborts all in-flight synthesis calls via AbortController and transitions the queue to idle.
  • Priority insertion -- pushImmediate() cancels pending segments and inserts new text at the front of the queue.
  • Per-segment lifecycle -- Each segment progresses through pending, synthesizing, synthesized, playing, played (or failed / cancelled) with timestamp tracking at every transition.
  • Typed events -- Subscribe to segment:start, segment:end, segment:error, queue:empty, queue:drain, and state:change events with full TypeScript typing.
  • Callback hooks -- Optional onSegmentStart, onSegmentEnd, onSegmentError, and onQueueEmpty callbacks on QueueOptions.
  • Queue statistics -- getStats() returns total, completed, failed, cancelled, and pending segment counts, total duration, and total characters processed.
  • Custom splitting -- Provide a custom splitting.custom function to override the built-in sentence splitter.
  • Zero runtime dependencies -- Only dev dependencies for TypeScript, ESLint, and Vitest.

API Reference

createQueue(options: QueueOptions): TTSQueue

Factory function that creates and returns a TTSQueue instance.

import{createQueue}from'tts-queue';constqueue=createQueue({
provider,// Required: TTSProvider
sink,// Required: AudioSinksplitting: {// Optional: splitting configurationmaxChars: 200,minChars: 10,on: 'sentence',custom: (text)=>text.split('\n'),},concurrency: 1,// Optional: concurrent synthesis limitprefetchCount: 2,// Optional: segments to pre-fetchonSegmentStart: (segment)=>{/* ... */},onSegmentEnd: (segment)=>{/* ... */},onSegmentError: (error,segment)=>{/* ... */},onQueueEmpty: ()=>{/* ... */},});

TTSQueue

The queue instance returned by createQueue. Implements the TTSQueue interface.

queue.push(text: string): Promise<SegmentInfo[]>

Split text into sentences and append segments to the queue. Returns the created SegmentInfo objects. Processing begins immediately.

constsegments=awaitqueue.push('First sentence. Second sentence. Third sentence.');console.log(segments.length);// 3

queue.pushImmediate(text: string): Promise<SegmentInfo[]>

Cancel all pending and in-progress segments, then insert new text at the front of the queue. Use this for interruption-and-replace patterns (e.g., the user asks a new question while the previous answer is still playing).

constsegments=awaitqueue.pushImmediate('Interrupting with new content.');

queue.pause(): Promise<void>

Pause playback. The queue transitions to paused state. If the sink implements pause(), it is called.

awaitqueue.pause();console.log(queue.getState());// 'paused'

queue.resume(): Promise<void>

Resume playback after a pause. The queue transitions back to playing state. If the sink implements resume(), it is called. Segment processing resumes automatically.

awaitqueue.resume();console.log(queue.getState());// 'playing'

queue.cancel(ids?: string[]): Promise<CancelResult>

Cancel segments. When called with no arguments, cancels all non-terminal segments, aborts in-flight synthesis via AbortController, and resets the queue to idle. When called with specific segment IDs, cancels only those segments.

// Cancel everythingconstresult=awaitqueue.cancel();console.log(result.cancelled);// number of segments cancelledconsole.log(result.ids);// IDs of cancelled segments// Cancel specific segmentsconstresult2=awaitqueue.cancel(['segment-id-1','segment-id-2']);

queue.drain(): Promise<void>

Wait for all segments to finish processing. Transitions the queue to draining state while segments complete, then resolves when the queue reaches idle.

awaitqueue.push('Some text to speak.');awaitqueue.drain();// resolves when all audio has finished playing

queue.close(): Promise<void>

Cancel all activity and permanently close the queue. Transitions to closed state. No more pushes are accepted after this call.

awaitqueue.close();console.log(queue.getState());// 'closed'

queue.getState(): QueueState

Returns the current queue state: 'idle', 'playing', 'paused', 'draining', or 'closed'.

queue.getStats(): QueueStats

Returns cumulative statistics for all segments processed by the queue.

conststats=queue.getStats();// {// totalSegments: 5,// completedSegments: 3,// failedSegments: 0,// cancelledSegments: 2,// pendingSegments: 0,// totalDurationMs: 4500,// totalChars: 312,// }

queue.getSegments(): SegmentInfo[]

Returns a snapshot (copy) of all segments and their current state.

queue.on<K>(event: K, listener: TTSQueueEvents[K]): void

Subscribe to a typed queue event.

queue.off<K>(event: K, listener: TTSQueueEvents[K]): void

Unsubscribe from a typed queue event.


Events

EventPayloadDescription
segment:startSegmentInfoFired when synthesis begins for a segment
segment:endSegmentInfoFired when a segment finishes playing
segment:errorTTSQueueError, SegmentInfoFired on synthesis or playback error
queue:empty--Fired when all segments have been played
queue:drain--Fired when the queue has been drained
state:changeQueueStateFired on every queue state transition

splitSentences(text: string, options?: SplitOptions): string[]

Standalone sentence splitter. Used internally by createQueue, but also exported for direct use.

import{splitSentences}from'tts-queue';splitSentences('Dr. Smith went to the store. She bought apples.');// ['Dr. Smith went to the store.', 'She bought apples.']splitSentences('Hello. World.',{minLength: 1});// ['Hello.', 'World.']splitSentences('');// []

SplitOptions

PropertyTypeDefaultDescription
minLengthnumber10Minimum segment length in characters. Shorter segments are filtered out.
maxLengthnumber200Maximum segment length. Longer segments are split at clause boundaries.
preserveWhitespacebooleanfalseWhen true, preserves leading/trailing whitespace in segments.

The built-in splitter handles:

  • Abbreviations: Mr., Mrs., Ms., Dr., Prof., St., Jr., Sr., vs., etc., e.g., i.e., Fig., Approx., Dept., Est., Govt., Inc., Corp., Ltd., Co., U.S., U.K., U.N.
  • Decimal numbers: 98.6, 3.14, $9.99 -- periods between digits are not treated as boundaries.
  • Ellipsis: ... -- consecutive periods are not treated as boundaries.
  • URLs and domains: example.com -- periods followed immediately by a letter or digit (no space) are not treated as boundaries.
  • Single-letter initials: A. B. Smith -- single uppercase letters followed by a period are not treated as boundaries.
  • Quoted strings: Sentence boundaries inside quoted strings (double quotes, smart quotes) are ignored.
  • Long sentence fallback: Sentences exceeding maxLength are split at semicolons, em dashes (---, unicode em dash), then commas (only when both halves meet minLength).

createSegment(text: string, index: number): SegmentInfo

Create a new segment in pending state with a unique UUID, timestamp, and the given text and index.

import{createSegment}from'tts-queue';constsegment=createSegment('Hello world.',0);// { id: 'uuid', text: 'Hello world.', index: 0, state: 'pending', addedAt: Date }

transitionSegment(segment: SegmentInfo, newState: SegmentState, extra?: Partial<SegmentInfo>): SegmentInfo

Immutably transition a segment to a new state. Enforces the valid state transition graph and sets appropriate timestamps (synthesisStartedAt, synthesisCompletedAt, playbackStartedAt, playbackCompletedAt). Throws TTSQueueError on invalid transitions. The original segment object is never mutated.

Valid transitions:

pending -> synthesizing | cancelled
synthesizing -> synthesized | failed | cancelled
synthesized -> playing | cancelled
playing -> played | failed | cancelled
played -> (terminal)
failed -> (terminal)
cancelled -> (terminal)

TTSQueueError

Custom error class for all errors originating from the queue. Extends Error with additional context fields.

import{TTSQueueError}from'tts-queue';try{// ...}catch(err){if(errinstanceofTTSQueueError){console.log(err.name);// 'TTSQueueError'console.log(err.stage);// 'synthesis' | 'playback' | 'splitting' | 'internal'console.log(err.cause);// underlying Error, if anyconsole.log(err.segment);// SegmentInfo, if associated with a segment}}

Error Factory Functions

FunctionStageDescription
synthErrorsynthesisTTS provider synthesis failure
playbackErrorplaybackAudio sink playback failure
splittingErrorsplittingText splitting failure
internalErrorinternalQueue internal error (invalid state)

Each factory accepts (message: string, cause?: Error, segment?: SegmentInfo) and returns a TTSQueueError.

import{synthError}from'tts-queue';consterr=synthError('Provider timeout',newError('ETIMEDOUT'),segmentInfo);

Configuration

QueueOptions

PropertyTypeRequiredDefaultDescription
providerTTSProviderYes--TTS synthesis provider
sinkAudioSinkYes--Audio output destination
splittingSplittingOptionsNo{}Sentence splitting configuration
concurrencynumberNo1Maximum concurrent synthesis calls
prefetchCountnumberNo2Number of segments to pre-fetch ahead of playback
onSegmentStart(segment: SegmentInfo) => voidNo--Callback when a segment begins synthesis
onSegmentEnd(segment: SegmentInfo) => voidNo--Callback when a segment finishes playback
onSegmentError(error: TTSQueueError, segment) => voidNo--Callback on segment-level errors
onQueueEmpty() => voidNo--Callback when all segments have completed

SplittingOptions

PropertyTypeDefaultDescription
maxCharsnumber200Maximum segment length before clause-boundary split
minCharsnumber10Minimum segment length; shorter segments are filtered
on'sentence' | 'word' | 'paragraph''sentence'Splitting strategy hint
custom(text: string) => string[]--Custom splitter function; overrides built-in logic

TTSProvider Interface

interfaceTTSProvider{synthesize(text: string,options?: SynthesisOptions): Promise<AudioData>;synthesizeStream?(text: string,options?: SynthesisOptions): AsyncIterable<AudioChunk>;}
MethodRequiredDescription
synthesizeYesSynthesize text into a complete audio buffer
synthesizeStreamNoSynthesize text as a stream of audio chunks

SynthesisOptions

PropertyTypeDescription
voicestringVoice identifier for the TTS engine
speednumberPlayback speed multiplier
formatAudioFormatDesired output audio format
sampleRatenumberDesired output sample rate in Hz

AudioSink Interface

interfaceAudioSink{play(audio: AudioData,segment: SegmentInfo): Promise<void>;pause?(): Promise<void>;resume?(): Promise<void>;stop?(): Promise<void>;}
MethodRequiredDescription
playYesPlay audio data for a given segment
pauseNoPause current playback
resumeNoResume paused playback
stopNoImmediately stop all playback

AudioData

PropertyTypeRequiredDescription
bufferBufferYesRaw audio bytes
formatAudioFormatYesAudio codec: 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'
sizeBytesnumberYesSize of the audio buffer in bytes
sampleRatenumberNoSample rate in Hz
channelsnumberNoNumber of audio channels
durationMsnumberNoDuration of the audio in milliseconds

SegmentInfo

PropertyTypeDescription
idstringUnique UUID for the segment
textstringSource text for the segment
indexnumber0-based position in the queue
stateSegmentStateCurrent lifecycle state
addedAtDateTimestamp when the segment was created
synthesisStartedAtDateTimestamp when synthesis began (optional)
synthesisCompletedAtDateTimestamp when synthesis completed (optional)
playbackStartedAtDateTimestamp when playback began (optional)
playbackCompletedAtDateTimestamp when playback completed (optional)
durationMsnumberAudio duration in milliseconds (optional)
errorErrorError that caused failure (optional)

Error Handling

All errors emitted by the queue are instances of TTSQueueError with a stage property indicating where the error originated.

Synthesis Errors

When a TTS provider's synthesize() call throws or rejects, the segment transitions to failed, a segment:error event is emitted with stage: 'synthesis', and the queue advances to the next segment. The queue does not stop.

queue.on('segment:error',(error,segment)=>{if(error.stage==='synthesis'){console.error(`Synthesis failed for "${segment.text}":`,error.cause);}});

Playback Errors

When the audio sink's play() method throws, the segment transitions to failed, a segment:error event is emitted with stage: 'playback', and the queue advances to the next segment.

Cancellation Errors

If a synthesis call is aborted via cancel(), the AbortController signal fires and the segment transitions to cancelled without emitting an error event. This is the expected path for interruptions.

Closed Queue

Calling push() or pushImmediate() on a closed queue throws a TTSQueueError with stage: 'internal' and the message "Queue is closed".

Invalid State Transitions

Attempting an invalid segment state transition (e.g., pending to played) throws a TTSQueueError with stage: 'internal'. This guards against programming errors in provider or sink implementations.


Advanced Usage

Priority Interruption

Replace the current playback with new content immediately:

// User asks a new question while the previous answer is playingawaitqueue.pushImmediate('Here is the answer to your new question.');// All pending/in-progress segments are cancelled, new text takes priority

Selective Cancellation

Cancel specific segments by ID while allowing others to continue:

constsegments=awaitqueue.push('Sentence one. Sentence two. Sentence three.');// Cancel only the last segmentawaitqueue.cancel([segments[2].id]);

Custom Sentence Splitting

Override the built-in splitter with your own logic:

constqueue=createQueue({
provider,
sink,splitting: {custom: (text)=>text.split(/\n\n/),// Split on double newlines},});

Event-Driven Progress Tracking

queue.on('segment:start',(segment)=>{console.log(`Synthesizing: "${segment.text}" (segment ${segment.index})`);});queue.on('segment:end',(segment)=>{console.log(`Finished: "${segment.text}" (${segment.durationMs}ms)`);});queue.on('state:change',(state)=>{console.log(`Queue state: ${state}`);});queue.on('queue:empty',()=>{console.log('All segments processed');});

Monitoring with Callbacks

constqueue=createQueue({
provider,
sink,onSegmentStart: (seg)=>metrics.trackSynthesisStart(seg.id),onSegmentEnd: (seg)=>metrics.trackSynthesisEnd(seg.id,seg.durationMs),onSegmentError: (err,seg)=>logger.error({ err,segmentId: seg.id}),onQueueEmpty: ()=>logger.info('Queue drained'),});

OpenAI TTS Provider Example

importOpenAIfrom'openai';importtype{TTSProvider,AudioData}from'tts-queue';constopenai=newOpenAI();constopenaiProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitopenai.audio.speech.create({model: 'tts-1',voice: 'alloy',input: text,response_format: 'mp3',});constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

ElevenLabs TTS Provider Example

importtype{TTSProvider,AudioData}from'tts-queue';constelevenLabsProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitfetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,{method: 'POST',headers: {'xi-api-key': process.env.ELEVENLABS_API_KEY!,'Content-Type': 'application/json',},body: JSON.stringify({ text,model_id: 'eleven_monolingual_v1'}),},);constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

Test Buffer Sink

Collect all audio data in memory for assertions:

importtype{AudioSink,AudioData,SegmentInfo}from'tts-queue';functioncreateTestSink(): AudioSink&{played: SegmentInfo[]}{constplayed: SegmentInfo[]=[];return{
played,asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{played.push(segment);},};}

TypeScript

tts-queue is written in TypeScript and ships with full type declarations (dist/index.d.ts). All exports are fully typed.

Exported Types

importtype{// Core interfacesTTSQueue,TTSProvider,AudioSink,QueueOptions,// Data typesAudioData,AudioChunk,AudioFormat,// 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'SynthesisOptions,// Segment typesSegmentInfo,SegmentState,// 'pending' | 'synthesizing' | 'synthesized' | 'playing'// | 'played' | 'cancelled' | 'failed'// Queue typesQueueState,// 'idle' | 'playing' | 'paused' | 'draining' | 'closed'QueueStats,CancelResult,TTSQueueEvents,// SplittingSplittingOptions,SplitOptions,// ErrorsTTSQueueStage,// 'synthesis' | 'playback' | 'splitting' | 'internal'}from'tts-queue';

Exported Values

import{createQueue,splitSentences,createSegment,transitionSegment,TTSQueueError,synthError,playbackError,splittingError,internalError,}from'tts-queue';

License

MIT

About

TTS audio streaming manager with sentence-boundary queuing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

tts-queue

TTS audio streaming manager with sentence-boundary queuing, gapless playback, and fast interruption handling.

npm versionnpm downloadslicensenodetypes


Description

tts-queue is the orchestration layer between text that needs to be spoken and audio that the user hears. It accepts text -- either as complete strings or as streaming token sequences from an LLM -- splits it into sentence-sized segments, sends each segment to a pluggable TTS provider for audio synthesis, and plays the resulting audio back-to-back through a pluggable audio sink.

The package handles every concern that sits between those two endpoints: sentence boundary detection (abbreviations, decimals, ellipsis, URLs), ordered FIFO playback queuing, per-segment lifecycle state management, cancellation of in-flight synthesis calls via AbortController, pause/resume, and error recovery. It replaces the 200-400 lines of async queue management, timer coordination, and provider-specific plumbing that every voice AI application otherwise implements from scratch.

Zero runtime dependencies. TypeScript-first. Provider-agnostic.


Installation

npm install tts-queue

Requires Node.js 18 or later.


Quick Start

import{createQueue}from'tts-queue';importtype{TTSProvider,AudioSink,AudioData,SegmentInfo}from'tts-queue';// 1. Define a TTS provider (wrap any TTS SDK)constprovider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaityourTTSClient.synthesize(text);return{buffer: response.audioBuffer,format: 'mp3',sizeBytes: response.audioBuffer.length,durationMs: response.durationMs,};},};// 2. Define an audio sink (where audio goes)constsink: AudioSink={asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{awaitspeaker.play(audio.buffer);},};// 3. Create the queue and push textconstqueue=createQueue({ provider, sink });awaitqueue.push('Hello there. How are you doing today? The weather is nice.');// Sentences are split, synthesized, and played in order automatically.// 4. Wait for all segments to finishawaitqueue.drain();// 5. Clean upawaitqueue.close();

Features

  • Sentence-boundary splitting -- Automatically segments text at sentence boundaries with handling for abbreviations (Dr., Mr., Mrs., etc.), decimal numbers, ellipsis, and URLs/domains.
  • Clause-boundary fallback -- Long sentences exceeding maxChars are split at semicolons, em dashes, and commas.
  • Short segment filtering -- Segments shorter than minChars are filtered to prevent choppy single-word synthesis.
  • Provider-agnostic -- Works with any TTS provider through a simple TTSProvider interface. Swap providers without rewriting queue logic.
  • Pluggable audio sinks -- Route audio to speakers, files, WebSockets, or test buffers via the AudioSink interface.
  • Ordered FIFO playback -- Segments play in strict sequential order.
  • Pause / Resume -- Suspend and resume playback without losing queue position. Calls through to sink pause() / resume() when available.
  • Fast interruption -- cancel() aborts all in-flight synthesis calls via AbortController and transitions the queue to idle.
  • Priority insertion -- pushImmediate() cancels pending segments and inserts new text at the front of the queue.
  • Per-segment lifecycle -- Each segment progresses through pending, synthesizing, synthesized, playing, played (or failed / cancelled) with timestamp tracking at every transition.
  • Typed events -- Subscribe to segment:start, segment:end, segment:error, queue:empty, queue:drain, and state:change events with full TypeScript typing.
  • Callback hooks -- Optional onSegmentStart, onSegmentEnd, onSegmentError, and onQueueEmpty callbacks on QueueOptions.
  • Queue statistics -- getStats() returns total, completed, failed, cancelled, and pending segment counts, total duration, and total characters processed.
  • Custom splitting -- Provide a custom splitting.custom function to override the built-in sentence splitter.
  • Zero runtime dependencies -- Only dev dependencies for TypeScript, ESLint, and Vitest.

API Reference

createQueue(options: QueueOptions): TTSQueue

Factory function that creates and returns a TTSQueue instance.

import{createQueue}from'tts-queue';constqueue=createQueue({
provider,// Required: TTSProvider
sink,// Required: AudioSinksplitting: {// Optional: splitting configurationmaxChars: 200,minChars: 10,on: 'sentence',custom: (text)=>text.split('\n'),},concurrency: 1,// Optional: concurrent synthesis limitprefetchCount: 2,// Optional: segments to pre-fetchonSegmentStart: (segment)=>{/* ... */},onSegmentEnd: (segment)=>{/* ... */},onSegmentError: (error,segment)=>{/* ... */},onQueueEmpty: ()=>{/* ... */},});

TTSQueue

The queue instance returned by createQueue. Implements the TTSQueue interface.

queue.push(text: string): Promise<SegmentInfo[]>

Split text into sentences and append segments to the queue. Returns the created SegmentInfo objects. Processing begins immediately.

constsegments=awaitqueue.push('First sentence. Second sentence. Third sentence.');console.log(segments.length);// 3

queue.pushImmediate(text: string): Promise<SegmentInfo[]>

Cancel all pending and in-progress segments, then insert new text at the front of the queue. Use this for interruption-and-replace patterns (e.g., the user asks a new question while the previous answer is still playing).

constsegments=awaitqueue.pushImmediate('Interrupting with new content.');

queue.pause(): Promise<void>

Pause playback. The queue transitions to paused state. If the sink implements pause(), it is called.

awaitqueue.pause();console.log(queue.getState());// 'paused'

queue.resume(): Promise<void>

Resume playback after a pause. The queue transitions back to playing state. If the sink implements resume(), it is called. Segment processing resumes automatically.

awaitqueue.resume();console.log(queue.getState());// 'playing'

queue.cancel(ids?: string[]): Promise<CancelResult>

Cancel segments. When called with no arguments, cancels all non-terminal segments, aborts in-flight synthesis via AbortController, and resets the queue to idle. When called with specific segment IDs, cancels only those segments.

// Cancel everythingconstresult=awaitqueue.cancel();console.log(result.cancelled);// number of segments cancelledconsole.log(result.ids);// IDs of cancelled segments// Cancel specific segmentsconstresult2=awaitqueue.cancel(['segment-id-1','segment-id-2']);

queue.drain(): Promise<void>

Wait for all segments to finish processing. Transitions the queue to draining state while segments complete, then resolves when the queue reaches idle.

awaitqueue.push('Some text to speak.');awaitqueue.drain();// resolves when all audio has finished playing

queue.close(): Promise<void>

Cancel all activity and permanently close the queue. Transitions to closed state. No more pushes are accepted after this call.

awaitqueue.close();console.log(queue.getState());// 'closed'

queue.getState(): QueueState

Returns the current queue state: 'idle', 'playing', 'paused', 'draining', or 'closed'.

queue.getStats(): QueueStats

Returns cumulative statistics for all segments processed by the queue.

conststats=queue.getStats();// {// totalSegments: 5,// completedSegments: 3,// failedSegments: 0,// cancelledSegments: 2,// pendingSegments: 0,// totalDurationMs: 4500,// totalChars: 312,// }

queue.getSegments(): SegmentInfo[]

Returns a snapshot (copy) of all segments and their current state.

queue.on<K>(event: K, listener: TTSQueueEvents[K]): void

Subscribe to a typed queue event.

queue.off<K>(event: K, listener: TTSQueueEvents[K]): void

Unsubscribe from a typed queue event.


Events

EventPayloadDescription
segment:startSegmentInfoFired when synthesis begins for a segment
segment:endSegmentInfoFired when a segment finishes playing
segment:errorTTSQueueError, SegmentInfoFired on synthesis or playback error
queue:empty--Fired when all segments have been played
queue:drain--Fired when the queue has been drained
state:changeQueueStateFired on every queue state transition

splitSentences(text: string, options?: SplitOptions): string[]

Standalone sentence splitter. Used internally by createQueue, but also exported for direct use.

import{splitSentences}from'tts-queue';splitSentences('Dr. Smith went to the store. She bought apples.');// ['Dr. Smith went to the store.', 'She bought apples.']splitSentences('Hello. World.',{minLength: 1});// ['Hello.', 'World.']splitSentences('');// []

SplitOptions

PropertyTypeDefaultDescription
minLengthnumber10Minimum segment length in characters. Shorter segments are filtered out.
maxLengthnumber200Maximum segment length. Longer segments are split at clause boundaries.
preserveWhitespacebooleanfalseWhen true, preserves leading/trailing whitespace in segments.

The built-in splitter handles:

  • Abbreviations: Mr., Mrs., Ms., Dr., Prof., St., Jr., Sr., vs., etc., e.g., i.e., Fig., Approx., Dept., Est., Govt., Inc., Corp., Ltd., Co., U.S., U.K., U.N.
  • Decimal numbers: 98.6, 3.14, $9.99 -- periods between digits are not treated as boundaries.
  • Ellipsis: ... -- consecutive periods are not treated as boundaries.
  • URLs and domains: example.com -- periods followed immediately by a letter or digit (no space) are not treated as boundaries.
  • Single-letter initials: A. B. Smith -- single uppercase letters followed by a period are not treated as boundaries.
  • Quoted strings: Sentence boundaries inside quoted strings (double quotes, smart quotes) are ignored.
  • Long sentence fallback: Sentences exceeding maxLength are split at semicolons, em dashes (---, unicode em dash), then commas (only when both halves meet minLength).

createSegment(text: string, index: number): SegmentInfo

Create a new segment in pending state with a unique UUID, timestamp, and the given text and index.

import{createSegment}from'tts-queue';constsegment=createSegment('Hello world.',0);// { id: 'uuid', text: 'Hello world.', index: 0, state: 'pending', addedAt: Date }

transitionSegment(segment: SegmentInfo, newState: SegmentState, extra?: Partial<SegmentInfo>): SegmentInfo

Immutably transition a segment to a new state. Enforces the valid state transition graph and sets appropriate timestamps (synthesisStartedAt, synthesisCompletedAt, playbackStartedAt, playbackCompletedAt). Throws TTSQueueError on invalid transitions. The original segment object is never mutated.

Valid transitions:

pending -> synthesizing | cancelled
synthesizing -> synthesized | failed | cancelled
synthesized -> playing | cancelled
playing -> played | failed | cancelled
played -> (terminal)
failed -> (terminal)
cancelled -> (terminal)

TTSQueueError

Custom error class for all errors originating from the queue. Extends Error with additional context fields.

import{TTSQueueError}from'tts-queue';try{// ...}catch(err){if(errinstanceofTTSQueueError){console.log(err.name);// 'TTSQueueError'console.log(err.stage);// 'synthesis' | 'playback' | 'splitting' | 'internal'console.log(err.cause);// underlying Error, if anyconsole.log(err.segment);// SegmentInfo, if associated with a segment}}

Error Factory Functions

FunctionStageDescription
synthErrorsynthesisTTS provider synthesis failure
playbackErrorplaybackAudio sink playback failure
splittingErrorsplittingText splitting failure
internalErrorinternalQueue internal error (invalid state)

Each factory accepts (message: string, cause?: Error, segment?: SegmentInfo) and returns a TTSQueueError.

import{synthError}from'tts-queue';consterr=synthError('Provider timeout',newError('ETIMEDOUT'),segmentInfo);

Configuration

QueueOptions

PropertyTypeRequiredDefaultDescription
providerTTSProviderYes--TTS synthesis provider
sinkAudioSinkYes--Audio output destination
splittingSplittingOptionsNo{}Sentence splitting configuration
concurrencynumberNo1Maximum concurrent synthesis calls
prefetchCountnumberNo2Number of segments to pre-fetch ahead of playback
onSegmentStart(segment: SegmentInfo) => voidNo--Callback when a segment begins synthesis
onSegmentEnd(segment: SegmentInfo) => voidNo--Callback when a segment finishes playback
onSegmentError(error: TTSQueueError, segment) => voidNo--Callback on segment-level errors
onQueueEmpty() => voidNo--Callback when all segments have completed

SplittingOptions

PropertyTypeDefaultDescription
maxCharsnumber200Maximum segment length before clause-boundary split
minCharsnumber10Minimum segment length; shorter segments are filtered
on'sentence' | 'word' | 'paragraph''sentence'Splitting strategy hint
custom(text: string) => string[]--Custom splitter function; overrides built-in logic

TTSProvider Interface

interfaceTTSProvider{synthesize(text: string,options?: SynthesisOptions): Promise<AudioData>;synthesizeStream?(text: string,options?: SynthesisOptions): AsyncIterable<AudioChunk>;}
MethodRequiredDescription
synthesizeYesSynthesize text into a complete audio buffer
synthesizeStreamNoSynthesize text as a stream of audio chunks

SynthesisOptions

PropertyTypeDescription
voicestringVoice identifier for the TTS engine
speednumberPlayback speed multiplier
formatAudioFormatDesired output audio format
sampleRatenumberDesired output sample rate in Hz

AudioSink Interface

interfaceAudioSink{play(audio: AudioData,segment: SegmentInfo): Promise<void>;pause?(): Promise<void>;resume?(): Promise<void>;stop?(): Promise<void>;}
MethodRequiredDescription
playYesPlay audio data for a given segment
pauseNoPause current playback
resumeNoResume paused playback
stopNoImmediately stop all playback

AudioData

PropertyTypeRequiredDescription
bufferBufferYesRaw audio bytes
formatAudioFormatYesAudio codec: 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'
sizeBytesnumberYesSize of the audio buffer in bytes
sampleRatenumberNoSample rate in Hz
channelsnumberNoNumber of audio channels
durationMsnumberNoDuration of the audio in milliseconds

SegmentInfo

PropertyTypeDescription
idstringUnique UUID for the segment
textstringSource text for the segment
indexnumber0-based position in the queue
stateSegmentStateCurrent lifecycle state
addedAtDateTimestamp when the segment was created
synthesisStartedAtDateTimestamp when synthesis began (optional)
synthesisCompletedAtDateTimestamp when synthesis completed (optional)
playbackStartedAtDateTimestamp when playback began (optional)
playbackCompletedAtDateTimestamp when playback completed (optional)
durationMsnumberAudio duration in milliseconds (optional)
errorErrorError that caused failure (optional)

Error Handling

All errors emitted by the queue are instances of TTSQueueError with a stage property indicating where the error originated.

Synthesis Errors

When a TTS provider's synthesize() call throws or rejects, the segment transitions to failed, a segment:error event is emitted with stage: 'synthesis', and the queue advances to the next segment. The queue does not stop.

queue.on('segment:error',(error,segment)=>{if(error.stage==='synthesis'){console.error(`Synthesis failed for "${segment.text}":`,error.cause);}});

Playback Errors

When the audio sink's play() method throws, the segment transitions to failed, a segment:error event is emitted with stage: 'playback', and the queue advances to the next segment.

Cancellation Errors

If a synthesis call is aborted via cancel(), the AbortController signal fires and the segment transitions to cancelled without emitting an error event. This is the expected path for interruptions.

Closed Queue

Calling push() or pushImmediate() on a closed queue throws a TTSQueueError with stage: 'internal' and the message "Queue is closed".

Invalid State Transitions

Attempting an invalid segment state transition (e.g., pending to played) throws a TTSQueueError with stage: 'internal'. This guards against programming errors in provider or sink implementations.


Advanced Usage

Priority Interruption

Replace the current playback with new content immediately:

// User asks a new question while the previous answer is playingawaitqueue.pushImmediate('Here is the answer to your new question.');// All pending/in-progress segments are cancelled, new text takes priority

Selective Cancellation

Cancel specific segments by ID while allowing others to continue:

constsegments=awaitqueue.push('Sentence one. Sentence two. Sentence three.');// Cancel only the last segmentawaitqueue.cancel([segments[2].id]);

Custom Sentence Splitting

Override the built-in splitter with your own logic:

constqueue=createQueue({
provider,
sink,splitting: {custom: (text)=>text.split(/\n\n/),// Split on double newlines},});

Event-Driven Progress Tracking

queue.on('segment:start',(segment)=>{console.log(`Synthesizing: "${segment.text}" (segment ${segment.index})`);});queue.on('segment:end',(segment)=>{console.log(`Finished: "${segment.text}" (${segment.durationMs}ms)`);});queue.on('state:change',(state)=>{console.log(`Queue state: ${state}`);});queue.on('queue:empty',()=>{console.log('All segments processed');});

Monitoring with Callbacks

constqueue=createQueue({
provider,
sink,onSegmentStart: (seg)=>metrics.trackSynthesisStart(seg.id),onSegmentEnd: (seg)=>metrics.trackSynthesisEnd(seg.id,seg.durationMs),onSegmentError: (err,seg)=>logger.error({ err,segmentId: seg.id}),onQueueEmpty: ()=>logger.info('Queue drained'),});

OpenAI TTS Provider Example

importOpenAIfrom'openai';importtype{TTSProvider,AudioData}from'tts-queue';constopenai=newOpenAI();constopenaiProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitopenai.audio.speech.create({model: 'tts-1',voice: 'alloy',input: text,response_format: 'mp3',});constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

ElevenLabs TTS Provider Example

importtype{TTSProvider,AudioData}from'tts-queue';constelevenLabsProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitfetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,{method: 'POST',headers: {'xi-api-key': process.env.ELEVENLABS_API_KEY!,'Content-Type': 'application/json',},body: JSON.stringify({ text,model_id: 'eleven_monolingual_v1'}),},);constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

Test Buffer Sink

Collect all audio data in memory for assertions:

importtype{AudioSink,AudioData,SegmentInfo}from'tts-queue';functioncreateTestSink(): AudioSink&{played: SegmentInfo[]}{constplayed: SegmentInfo[]=[];return{
played,asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{played.push(segment);},};}

TypeScript

tts-queue is written in TypeScript and ships with full type declarations (dist/index.d.ts). All exports are fully typed.

Exported Types

importtype{// Core interfacesTTSQueue,TTSProvider,AudioSink,QueueOptions,// Data typesAudioData,AudioChunk,AudioFormat,// 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'SynthesisOptions,// Segment typesSegmentInfo,SegmentState,// 'pending' | 'synthesizing' | 'synthesized' | 'playing'// | 'played' | 'cancelled' | 'failed'// Queue typesQueueState,// 'idle' | 'playing' | 'paused' | 'draining' | 'closed'QueueStats,CancelResult,TTSQueueEvents,// SplittingSplittingOptions,SplitOptions,// ErrorsTTSQueueStage,// 'synthesis' | 'playback' | 'splitting' | 'internal'}from'tts-queue';

Exported Values

import{createQueue,splitSentences,createSegment,transitionSegment,TTSQueueError,synthError,playbackError,splittingError,internalError,}from'tts-queue';

License

MIT

About

TTS audio streaming manager with sentence-boundary queuing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

tts-queue

TTS audio streaming manager with sentence-boundary queuing, gapless playback, and fast interruption handling.

npm versionnpm downloadslicensenodetypes


Description

tts-queue is the orchestration layer between text that needs to be spoken and audio that the user hears. It accepts text -- either as complete strings or as streaming token sequences from an LLM -- splits it into sentence-sized segments, sends each segment to a pluggable TTS provider for audio synthesis, and plays the resulting audio back-to-back through a pluggable audio sink.

The package handles every concern that sits between those two endpoints: sentence boundary detection (abbreviations, decimals, ellipsis, URLs), ordered FIFO playback queuing, per-segment lifecycle state management, cancellation of in-flight synthesis calls via AbortController, pause/resume, and error recovery. It replaces the 200-400 lines of async queue management, timer coordination, and provider-specific plumbing that every voice AI application otherwise implements from scratch.

Zero runtime dependencies. TypeScript-first. Provider-agnostic.


Installation

npm install tts-queue

Requires Node.js 18 or later.


Quick Start

import{createQueue}from'tts-queue';importtype{TTSProvider,AudioSink,AudioData,SegmentInfo}from'tts-queue';// 1. Define a TTS provider (wrap any TTS SDK)constprovider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaityourTTSClient.synthesize(text);return{buffer: response.audioBuffer,format: 'mp3',sizeBytes: response.audioBuffer.length,durationMs: response.durationMs,};},};// 2. Define an audio sink (where audio goes)constsink: AudioSink={asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{awaitspeaker.play(audio.buffer);},};// 3. Create the queue and push textconstqueue=createQueue({ provider, sink });awaitqueue.push('Hello there. How are you doing today? The weather is nice.');// Sentences are split, synthesized, and played in order automatically.// 4. Wait for all segments to finishawaitqueue.drain();// 5. Clean upawaitqueue.close();

Features

  • Sentence-boundary splitting -- Automatically segments text at sentence boundaries with handling for abbreviations (Dr., Mr., Mrs., etc.), decimal numbers, ellipsis, and URLs/domains.
  • Clause-boundary fallback -- Long sentences exceeding maxChars are split at semicolons, em dashes, and commas.
  • Short segment filtering -- Segments shorter than minChars are filtered to prevent choppy single-word synthesis.
  • Provider-agnostic -- Works with any TTS provider through a simple TTSProvider interface. Swap providers without rewriting queue logic.
  • Pluggable audio sinks -- Route audio to speakers, files, WebSockets, or test buffers via the AudioSink interface.
  • Ordered FIFO playback -- Segments play in strict sequential order.
  • Pause / Resume -- Suspend and resume playback without losing queue position. Calls through to sink pause() / resume() when available.
  • Fast interruption -- cancel() aborts all in-flight synthesis calls via AbortController and transitions the queue to idle.
  • Priority insertion -- pushImmediate() cancels pending segments and inserts new text at the front of the queue.
  • Per-segment lifecycle -- Each segment progresses through pending, synthesizing, synthesized, playing, played (or failed / cancelled) with timestamp tracking at every transition.
  • Typed events -- Subscribe to segment:start, segment:end, segment:error, queue:empty, queue:drain, and state:change events with full TypeScript typing.
  • Callback hooks -- Optional onSegmentStart, onSegmentEnd, onSegmentError, and onQueueEmpty callbacks on QueueOptions.
  • Queue statistics -- getStats() returns total, completed, failed, cancelled, and pending segment counts, total duration, and total characters processed.
  • Custom splitting -- Provide a custom splitting.custom function to override the built-in sentence splitter.
  • Zero runtime dependencies -- Only dev dependencies for TypeScript, ESLint, and Vitest.

API Reference

createQueue(options: QueueOptions): TTSQueue

Factory function that creates and returns a TTSQueue instance.

import{createQueue}from'tts-queue';constqueue=createQueue({
provider,// Required: TTSProvider
sink,// Required: AudioSinksplitting: {// Optional: splitting configurationmaxChars: 200,minChars: 10,on: 'sentence',custom: (text)=>text.split('\n'),},concurrency: 1,// Optional: concurrent synthesis limitprefetchCount: 2,// Optional: segments to pre-fetchonSegmentStart: (segment)=>{/* ... */},onSegmentEnd: (segment)=>{/* ... */},onSegmentError: (error,segment)=>{/* ... */},onQueueEmpty: ()=>{/* ... */},});

TTSQueue

The queue instance returned by createQueue. Implements the TTSQueue interface.

queue.push(text: string): Promise<SegmentInfo[]>

Split text into sentences and append segments to the queue. Returns the created SegmentInfo objects. Processing begins immediately.

constsegments=awaitqueue.push('First sentence. Second sentence. Third sentence.');console.log(segments.length);// 3

queue.pushImmediate(text: string): Promise<SegmentInfo[]>

Cancel all pending and in-progress segments, then insert new text at the front of the queue. Use this for interruption-and-replace patterns (e.g., the user asks a new question while the previous answer is still playing).

constsegments=awaitqueue.pushImmediate('Interrupting with new content.');

queue.pause(): Promise<void>

Pause playback. The queue transitions to paused state. If the sink implements pause(), it is called.

awaitqueue.pause();console.log(queue.getState());// 'paused'

queue.resume(): Promise<void>

Resume playback after a pause. The queue transitions back to playing state. If the sink implements resume(), it is called. Segment processing resumes automatically.

awaitqueue.resume();console.log(queue.getState());// 'playing'

queue.cancel(ids?: string[]): Promise<CancelResult>

Cancel segments. When called with no arguments, cancels all non-terminal segments, aborts in-flight synthesis via AbortController, and resets the queue to idle. When called with specific segment IDs, cancels only those segments.

// Cancel everythingconstresult=awaitqueue.cancel();console.log(result.cancelled);// number of segments cancelledconsole.log(result.ids);// IDs of cancelled segments// Cancel specific segmentsconstresult2=awaitqueue.cancel(['segment-id-1','segment-id-2']);

queue.drain(): Promise<void>

Wait for all segments to finish processing. Transitions the queue to draining state while segments complete, then resolves when the queue reaches idle.

awaitqueue.push('Some text to speak.');awaitqueue.drain();// resolves when all audio has finished playing

queue.close(): Promise<void>

Cancel all activity and permanently close the queue. Transitions to closed state. No more pushes are accepted after this call.

awaitqueue.close();console.log(queue.getState());// 'closed'

queue.getState(): QueueState

Returns the current queue state: 'idle', 'playing', 'paused', 'draining', or 'closed'.

queue.getStats(): QueueStats

Returns cumulative statistics for all segments processed by the queue.

conststats=queue.getStats();// {// totalSegments: 5,// completedSegments: 3,// failedSegments: 0,// cancelledSegments: 2,// pendingSegments: 0,// totalDurationMs: 4500,// totalChars: 312,// }

queue.getSegments(): SegmentInfo[]

Returns a snapshot (copy) of all segments and their current state.

queue.on<K>(event: K, listener: TTSQueueEvents[K]): void

Subscribe to a typed queue event.

queue.off<K>(event: K, listener: TTSQueueEvents[K]): void

Unsubscribe from a typed queue event.


Events

EventPayloadDescription
segment:startSegmentInfoFired when synthesis begins for a segment
segment:endSegmentInfoFired when a segment finishes playing
segment:errorTTSQueueError, SegmentInfoFired on synthesis or playback error
queue:empty--Fired when all segments have been played
queue:drain--Fired when the queue has been drained
state:changeQueueStateFired on every queue state transition

splitSentences(text: string, options?: SplitOptions): string[]

Standalone sentence splitter. Used internally by createQueue, but also exported for direct use.

import{splitSentences}from'tts-queue';splitSentences('Dr. Smith went to the store. She bought apples.');// ['Dr. Smith went to the store.', 'She bought apples.']splitSentences('Hello. World.',{minLength: 1});// ['Hello.', 'World.']splitSentences('');// []

SplitOptions

PropertyTypeDefaultDescription
minLengthnumber10Minimum segment length in characters. Shorter segments are filtered out.
maxLengthnumber200Maximum segment length. Longer segments are split at clause boundaries.
preserveWhitespacebooleanfalseWhen true, preserves leading/trailing whitespace in segments.

The built-in splitter handles:

  • Abbreviations: Mr., Mrs., Ms., Dr., Prof., St., Jr., Sr., vs., etc., e.g., i.e., Fig., Approx., Dept., Est., Govt., Inc., Corp., Ltd., Co., U.S., U.K., U.N.
  • Decimal numbers: 98.6, 3.14, $9.99 -- periods between digits are not treated as boundaries.
  • Ellipsis: ... -- consecutive periods are not treated as boundaries.
  • URLs and domains: example.com -- periods followed immediately by a letter or digit (no space) are not treated as boundaries.
  • Single-letter initials: A. B. Smith -- single uppercase letters followed by a period are not treated as boundaries.
  • Quoted strings: Sentence boundaries inside quoted strings (double quotes, smart quotes) are ignored.
  • Long sentence fallback: Sentences exceeding maxLength are split at semicolons, em dashes (---, unicode em dash), then commas (only when both halves meet minLength).

createSegment(text: string, index: number): SegmentInfo

Create a new segment in pending state with a unique UUID, timestamp, and the given text and index.

import{createSegment}from'tts-queue';constsegment=createSegment('Hello world.',0);// { id: 'uuid', text: 'Hello world.', index: 0, state: 'pending', addedAt: Date }

transitionSegment(segment: SegmentInfo, newState: SegmentState, extra?: Partial<SegmentInfo>): SegmentInfo

Immutably transition a segment to a new state. Enforces the valid state transition graph and sets appropriate timestamps (synthesisStartedAt, synthesisCompletedAt, playbackStartedAt, playbackCompletedAt). Throws TTSQueueError on invalid transitions. The original segment object is never mutated.

Valid transitions:

pending -> synthesizing | cancelled
synthesizing -> synthesized | failed | cancelled
synthesized -> playing | cancelled
playing -> played | failed | cancelled
played -> (terminal)
failed -> (terminal)
cancelled -> (terminal)

TTSQueueError

Custom error class for all errors originating from the queue. Extends Error with additional context fields.

import{TTSQueueError}from'tts-queue';try{// ...}catch(err){if(errinstanceofTTSQueueError){console.log(err.name);// 'TTSQueueError'console.log(err.stage);// 'synthesis' | 'playback' | 'splitting' | 'internal'console.log(err.cause);// underlying Error, if anyconsole.log(err.segment);// SegmentInfo, if associated with a segment}}

Error Factory Functions

FunctionStageDescription
synthErrorsynthesisTTS provider synthesis failure
playbackErrorplaybackAudio sink playback failure
splittingErrorsplittingText splitting failure
internalErrorinternalQueue internal error (invalid state)

Each factory accepts (message: string, cause?: Error, segment?: SegmentInfo) and returns a TTSQueueError.

import{synthError}from'tts-queue';consterr=synthError('Provider timeout',newError('ETIMEDOUT'),segmentInfo);

Configuration

QueueOptions

PropertyTypeRequiredDefaultDescription
providerTTSProviderYes--TTS synthesis provider
sinkAudioSinkYes--Audio output destination
splittingSplittingOptionsNo{}Sentence splitting configuration
concurrencynumberNo1Maximum concurrent synthesis calls
prefetchCountnumberNo2Number of segments to pre-fetch ahead of playback
onSegmentStart(segment: SegmentInfo) => voidNo--Callback when a segment begins synthesis
onSegmentEnd(segment: SegmentInfo) => voidNo--Callback when a segment finishes playback
onSegmentError(error: TTSQueueError, segment) => voidNo--Callback on segment-level errors
onQueueEmpty() => voidNo--Callback when all segments have completed

SplittingOptions

PropertyTypeDefaultDescription
maxCharsnumber200Maximum segment length before clause-boundary split
minCharsnumber10Minimum segment length; shorter segments are filtered
on'sentence' | 'word' | 'paragraph''sentence'Splitting strategy hint
custom(text: string) => string[]--Custom splitter function; overrides built-in logic

TTSProvider Interface

interfaceTTSProvider{synthesize(text: string,options?: SynthesisOptions): Promise<AudioData>;synthesizeStream?(text: string,options?: SynthesisOptions): AsyncIterable<AudioChunk>;}
MethodRequiredDescription
synthesizeYesSynthesize text into a complete audio buffer
synthesizeStreamNoSynthesize text as a stream of audio chunks

SynthesisOptions

PropertyTypeDescription
voicestringVoice identifier for the TTS engine
speednumberPlayback speed multiplier
formatAudioFormatDesired output audio format
sampleRatenumberDesired output sample rate in Hz

AudioSink Interface

interfaceAudioSink{play(audio: AudioData,segment: SegmentInfo): Promise<void>;pause?(): Promise<void>;resume?(): Promise<void>;stop?(): Promise<void>;}
MethodRequiredDescription
playYesPlay audio data for a given segment
pauseNoPause current playback
resumeNoResume paused playback
stopNoImmediately stop all playback

AudioData

PropertyTypeRequiredDescription
bufferBufferYesRaw audio bytes
formatAudioFormatYesAudio codec: 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'
sizeBytesnumberYesSize of the audio buffer in bytes
sampleRatenumberNoSample rate in Hz
channelsnumberNoNumber of audio channels
durationMsnumberNoDuration of the audio in milliseconds

SegmentInfo

PropertyTypeDescription
idstringUnique UUID for the segment
textstringSource text for the segment
indexnumber0-based position in the queue
stateSegmentStateCurrent lifecycle state
addedAtDateTimestamp when the segment was created
synthesisStartedAtDateTimestamp when synthesis began (optional)
synthesisCompletedAtDateTimestamp when synthesis completed (optional)
playbackStartedAtDateTimestamp when playback began (optional)
playbackCompletedAtDateTimestamp when playback completed (optional)
durationMsnumberAudio duration in milliseconds (optional)
errorErrorError that caused failure (optional)

Error Handling

All errors emitted by the queue are instances of TTSQueueError with a stage property indicating where the error originated.

Synthesis Errors

When a TTS provider's synthesize() call throws or rejects, the segment transitions to failed, a segment:error event is emitted with stage: 'synthesis', and the queue advances to the next segment. The queue does not stop.

queue.on('segment:error',(error,segment)=>{if(error.stage==='synthesis'){console.error(`Synthesis failed for "${segment.text}":`,error.cause);}});

Playback Errors

When the audio sink's play() method throws, the segment transitions to failed, a segment:error event is emitted with stage: 'playback', and the queue advances to the next segment.

Cancellation Errors

If a synthesis call is aborted via cancel(), the AbortController signal fires and the segment transitions to cancelled without emitting an error event. This is the expected path for interruptions.

Closed Queue

Calling push() or pushImmediate() on a closed queue throws a TTSQueueError with stage: 'internal' and the message "Queue is closed".

Invalid State Transitions

Attempting an invalid segment state transition (e.g., pending to played) throws a TTSQueueError with stage: 'internal'. This guards against programming errors in provider or sink implementations.


Advanced Usage

Priority Interruption

Replace the current playback with new content immediately:

// User asks a new question while the previous answer is playingawaitqueue.pushImmediate('Here is the answer to your new question.');// All pending/in-progress segments are cancelled, new text takes priority

Selective Cancellation

Cancel specific segments by ID while allowing others to continue:

constsegments=awaitqueue.push('Sentence one. Sentence two. Sentence three.');// Cancel only the last segmentawaitqueue.cancel([segments[2].id]);

Custom Sentence Splitting

Override the built-in splitter with your own logic:

constqueue=createQueue({
provider,
sink,splitting: {custom: (text)=>text.split(/\n\n/),// Split on double newlines},});

Event-Driven Progress Tracking

queue.on('segment:start',(segment)=>{console.log(`Synthesizing: "${segment.text}" (segment ${segment.index})`);});queue.on('segment:end',(segment)=>{console.log(`Finished: "${segment.text}" (${segment.durationMs}ms)`);});queue.on('state:change',(state)=>{console.log(`Queue state: ${state}`);});queue.on('queue:empty',()=>{console.log('All segments processed');});

Monitoring with Callbacks

constqueue=createQueue({
provider,
sink,onSegmentStart: (seg)=>metrics.trackSynthesisStart(seg.id),onSegmentEnd: (seg)=>metrics.trackSynthesisEnd(seg.id,seg.durationMs),onSegmentError: (err,seg)=>logger.error({ err,segmentId: seg.id}),onQueueEmpty: ()=>logger.info('Queue drained'),});

OpenAI TTS Provider Example

importOpenAIfrom'openai';importtype{TTSProvider,AudioData}from'tts-queue';constopenai=newOpenAI();constopenaiProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitopenai.audio.speech.create({model: 'tts-1',voice: 'alloy',input: text,response_format: 'mp3',});constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

ElevenLabs TTS Provider Example

importtype{TTSProvider,AudioData}from'tts-queue';constelevenLabsProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitfetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,{method: 'POST',headers: {'xi-api-key': process.env.ELEVENLABS_API_KEY!,'Content-Type': 'application/json',},body: JSON.stringify({ text,model_id: 'eleven_monolingual_v1'}),},);constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

Test Buffer Sink

Collect all audio data in memory for assertions:

importtype{AudioSink,AudioData,SegmentInfo}from'tts-queue';functioncreateTestSink(): AudioSink&{played: SegmentInfo[]}{constplayed: SegmentInfo[]=[];return{
played,asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{played.push(segment);},};}

TypeScript

tts-queue is written in TypeScript and ships with full type declarations (dist/index.d.ts). All exports are fully typed.

Exported Types

importtype{// Core interfacesTTSQueue,TTSProvider,AudioSink,QueueOptions,// Data typesAudioData,AudioChunk,AudioFormat,// 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'SynthesisOptions,// Segment typesSegmentInfo,SegmentState,// 'pending' | 'synthesizing' | 'synthesized' | 'playing'// | 'played' | 'cancelled' | 'failed'// Queue typesQueueState,// 'idle' | 'playing' | 'paused' | 'draining' | 'closed'QueueStats,CancelResult,TTSQueueEvents,// SplittingSplittingOptions,SplitOptions,// ErrorsTTSQueueStage,// 'synthesis' | 'playback' | 'splitting' | 'internal'}from'tts-queue';

Exported Values

import{createQueue,splitSentences,createSegment,transitionSegment,TTSQueueError,synthError,playbackError,splittingError,internalError,}from'tts-queue';

License

MIT

About

TTS audio streaming manager with sentence-boundary queuing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

tts-queue

TTS audio streaming manager with sentence-boundary queuing, gapless playback, and fast interruption handling.

npm versionnpm downloadslicensenodetypes


Description

tts-queue is the orchestration layer between text that needs to be spoken and audio that the user hears. It accepts text -- either as complete strings or as streaming token sequences from an LLM -- splits it into sentence-sized segments, sends each segment to a pluggable TTS provider for audio synthesis, and plays the resulting audio back-to-back through a pluggable audio sink.

The package handles every concern that sits between those two endpoints: sentence boundary detection (abbreviations, decimals, ellipsis, URLs), ordered FIFO playback queuing, per-segment lifecycle state management, cancellation of in-flight synthesis calls via AbortController, pause/resume, and error recovery. It replaces the 200-400 lines of async queue management, timer coordination, and provider-specific plumbing that every voice AI application otherwise implements from scratch.

Zero runtime dependencies. TypeScript-first. Provider-agnostic.


Installation

npm install tts-queue

Requires Node.js 18 or later.


Quick Start

import{createQueue}from'tts-queue';importtype{TTSProvider,AudioSink,AudioData,SegmentInfo}from'tts-queue';// 1. Define a TTS provider (wrap any TTS SDK)constprovider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaityourTTSClient.synthesize(text);return{buffer: response.audioBuffer,format: 'mp3',sizeBytes: response.audioBuffer.length,durationMs: response.durationMs,};},};// 2. Define an audio sink (where audio goes)constsink: AudioSink={asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{awaitspeaker.play(audio.buffer);},};// 3. Create the queue and push textconstqueue=createQueue({ provider, sink });awaitqueue.push('Hello there. How are you doing today? The weather is nice.');// Sentences are split, synthesized, and played in order automatically.// 4. Wait for all segments to finishawaitqueue.drain();// 5. Clean upawaitqueue.close();

Features

  • Sentence-boundary splitting -- Automatically segments text at sentence boundaries with handling for abbreviations (Dr., Mr., Mrs., etc.), decimal numbers, ellipsis, and URLs/domains.
  • Clause-boundary fallback -- Long sentences exceeding maxChars are split at semicolons, em dashes, and commas.
  • Short segment filtering -- Segments shorter than minChars are filtered to prevent choppy single-word synthesis.
  • Provider-agnostic -- Works with any TTS provider through a simple TTSProvider interface. Swap providers without rewriting queue logic.
  • Pluggable audio sinks -- Route audio to speakers, files, WebSockets, or test buffers via the AudioSink interface.
  • Ordered FIFO playback -- Segments play in strict sequential order.
  • Pause / Resume -- Suspend and resume playback without losing queue position. Calls through to sink pause() / resume() when available.
  • Fast interruption -- cancel() aborts all in-flight synthesis calls via AbortController and transitions the queue to idle.
  • Priority insertion -- pushImmediate() cancels pending segments and inserts new text at the front of the queue.
  • Per-segment lifecycle -- Each segment progresses through pending, synthesizing, synthesized, playing, played (or failed / cancelled) with timestamp tracking at every transition.
  • Typed events -- Subscribe to segment:start, segment:end, segment:error, queue:empty, queue:drain, and state:change events with full TypeScript typing.
  • Callback hooks -- Optional onSegmentStart, onSegmentEnd, onSegmentError, and onQueueEmpty callbacks on QueueOptions.
  • Queue statistics -- getStats() returns total, completed, failed, cancelled, and pending segment counts, total duration, and total characters processed.
  • Custom splitting -- Provide a custom splitting.custom function to override the built-in sentence splitter.
  • Zero runtime dependencies -- Only dev dependencies for TypeScript, ESLint, and Vitest.

API Reference

createQueue(options: QueueOptions): TTSQueue

Factory function that creates and returns a TTSQueue instance.

import{createQueue}from'tts-queue';constqueue=createQueue({
provider,// Required: TTSProvider
sink,// Required: AudioSinksplitting: {// Optional: splitting configurationmaxChars: 200,minChars: 10,on: 'sentence',custom: (text)=>text.split('\n'),},concurrency: 1,// Optional: concurrent synthesis limitprefetchCount: 2,// Optional: segments to pre-fetchonSegmentStart: (segment)=>{/* ... */},onSegmentEnd: (segment)=>{/* ... */},onSegmentError: (error,segment)=>{/* ... */},onQueueEmpty: ()=>{/* ... */},});

TTSQueue

The queue instance returned by createQueue. Implements the TTSQueue interface.

queue.push(text: string): Promise<SegmentInfo[]>

Split text into sentences and append segments to the queue. Returns the created SegmentInfo objects. Processing begins immediately.

constsegments=awaitqueue.push('First sentence. Second sentence. Third sentence.');console.log(segments.length);// 3

queue.pushImmediate(text: string): Promise<SegmentInfo[]>

Cancel all pending and in-progress segments, then insert new text at the front of the queue. Use this for interruption-and-replace patterns (e.g., the user asks a new question while the previous answer is still playing).

constsegments=awaitqueue.pushImmediate('Interrupting with new content.');

queue.pause(): Promise<void>

Pause playback. The queue transitions to paused state. If the sink implements pause(), it is called.

awaitqueue.pause();console.log(queue.getState());// 'paused'

queue.resume(): Promise<void>

Resume playback after a pause. The queue transitions back to playing state. If the sink implements resume(), it is called. Segment processing resumes automatically.

awaitqueue.resume();console.log(queue.getState());// 'playing'

queue.cancel(ids?: string[]): Promise<CancelResult>

Cancel segments. When called with no arguments, cancels all non-terminal segments, aborts in-flight synthesis via AbortController, and resets the queue to idle. When called with specific segment IDs, cancels only those segments.

// Cancel everythingconstresult=awaitqueue.cancel();console.log(result.cancelled);// number of segments cancelledconsole.log(result.ids);// IDs of cancelled segments// Cancel specific segmentsconstresult2=awaitqueue.cancel(['segment-id-1','segment-id-2']);

queue.drain(): Promise<void>

Wait for all segments to finish processing. Transitions the queue to draining state while segments complete, then resolves when the queue reaches idle.

awaitqueue.push('Some text to speak.');awaitqueue.drain();// resolves when all audio has finished playing

queue.close(): Promise<void>

Cancel all activity and permanently close the queue. Transitions to closed state. No more pushes are accepted after this call.

awaitqueue.close();console.log(queue.getState());// 'closed'

queue.getState(): QueueState

Returns the current queue state: 'idle', 'playing', 'paused', 'draining', or 'closed'.

queue.getStats(): QueueStats

Returns cumulative statistics for all segments processed by the queue.

conststats=queue.getStats();// {// totalSegments: 5,// completedSegments: 3,// failedSegments: 0,// cancelledSegments: 2,// pendingSegments: 0,// totalDurationMs: 4500,// totalChars: 312,// }

queue.getSegments(): SegmentInfo[]

Returns a snapshot (copy) of all segments and their current state.

queue.on<K>(event: K, listener: TTSQueueEvents[K]): void

Subscribe to a typed queue event.

queue.off<K>(event: K, listener: TTSQueueEvents[K]): void

Unsubscribe from a typed queue event.


Events

EventPayloadDescription
segment:startSegmentInfoFired when synthesis begins for a segment
segment:endSegmentInfoFired when a segment finishes playing
segment:errorTTSQueueError, SegmentInfoFired on synthesis or playback error
queue:empty--Fired when all segments have been played
queue:drain--Fired when the queue has been drained
state:changeQueueStateFired on every queue state transition

splitSentences(text: string, options?: SplitOptions): string[]

Standalone sentence splitter. Used internally by createQueue, but also exported for direct use.

import{splitSentences}from'tts-queue';splitSentences('Dr. Smith went to the store. She bought apples.');// ['Dr. Smith went to the store.', 'She bought apples.']splitSentences('Hello. World.',{minLength: 1});// ['Hello.', 'World.']splitSentences('');// []

SplitOptions

PropertyTypeDefaultDescription
minLengthnumber10Minimum segment length in characters. Shorter segments are filtered out.
maxLengthnumber200Maximum segment length. Longer segments are split at clause boundaries.
preserveWhitespacebooleanfalseWhen true, preserves leading/trailing whitespace in segments.

The built-in splitter handles:

  • Abbreviations: Mr., Mrs., Ms., Dr., Prof., St., Jr., Sr., vs., etc., e.g., i.e., Fig., Approx., Dept., Est., Govt., Inc., Corp., Ltd., Co., U.S., U.K., U.N.
  • Decimal numbers: 98.6, 3.14, $9.99 -- periods between digits are not treated as boundaries.
  • Ellipsis: ... -- consecutive periods are not treated as boundaries.
  • URLs and domains: example.com -- periods followed immediately by a letter or digit (no space) are not treated as boundaries.
  • Single-letter initials: A. B. Smith -- single uppercase letters followed by a period are not treated as boundaries.
  • Quoted strings: Sentence boundaries inside quoted strings (double quotes, smart quotes) are ignored.
  • Long sentence fallback: Sentences exceeding maxLength are split at semicolons, em dashes (---, unicode em dash), then commas (only when both halves meet minLength).

createSegment(text: string, index: number): SegmentInfo

Create a new segment in pending state with a unique UUID, timestamp, and the given text and index.

import{createSegment}from'tts-queue';constsegment=createSegment('Hello world.',0);// { id: 'uuid', text: 'Hello world.', index: 0, state: 'pending', addedAt: Date }

transitionSegment(segment: SegmentInfo, newState: SegmentState, extra?: Partial<SegmentInfo>): SegmentInfo

Immutably transition a segment to a new state. Enforces the valid state transition graph and sets appropriate timestamps (synthesisStartedAt, synthesisCompletedAt, playbackStartedAt, playbackCompletedAt). Throws TTSQueueError on invalid transitions. The original segment object is never mutated.

Valid transitions:

pending -> synthesizing | cancelled
synthesizing -> synthesized | failed | cancelled
synthesized -> playing | cancelled
playing -> played | failed | cancelled
played -> (terminal)
failed -> (terminal)
cancelled -> (terminal)

TTSQueueError

Custom error class for all errors originating from the queue. Extends Error with additional context fields.

import{TTSQueueError}from'tts-queue';try{// ...}catch(err){if(errinstanceofTTSQueueError){console.log(err.name);// 'TTSQueueError'console.log(err.stage);// 'synthesis' | 'playback' | 'splitting' | 'internal'console.log(err.cause);// underlying Error, if anyconsole.log(err.segment);// SegmentInfo, if associated with a segment}}

Error Factory Functions

FunctionStageDescription
synthErrorsynthesisTTS provider synthesis failure
playbackErrorplaybackAudio sink playback failure
splittingErrorsplittingText splitting failure
internalErrorinternalQueue internal error (invalid state)

Each factory accepts (message: string, cause?: Error, segment?: SegmentInfo) and returns a TTSQueueError.

import{synthError}from'tts-queue';consterr=synthError('Provider timeout',newError('ETIMEDOUT'),segmentInfo);

Configuration

QueueOptions

PropertyTypeRequiredDefaultDescription
providerTTSProviderYes--TTS synthesis provider
sinkAudioSinkYes--Audio output destination
splittingSplittingOptionsNo{}Sentence splitting configuration
concurrencynumberNo1Maximum concurrent synthesis calls
prefetchCountnumberNo2Number of segments to pre-fetch ahead of playback
onSegmentStart(segment: SegmentInfo) => voidNo--Callback when a segment begins synthesis
onSegmentEnd(segment: SegmentInfo) => voidNo--Callback when a segment finishes playback
onSegmentError(error: TTSQueueError, segment) => voidNo--Callback on segment-level errors
onQueueEmpty() => voidNo--Callback when all segments have completed

SplittingOptions

PropertyTypeDefaultDescription
maxCharsnumber200Maximum segment length before clause-boundary split
minCharsnumber10Minimum segment length; shorter segments are filtered
on'sentence' | 'word' | 'paragraph''sentence'Splitting strategy hint
custom(text: string) => string[]--Custom splitter function; overrides built-in logic

TTSProvider Interface

interfaceTTSProvider{synthesize(text: string,options?: SynthesisOptions): Promise<AudioData>;synthesizeStream?(text: string,options?: SynthesisOptions): AsyncIterable<AudioChunk>;}
MethodRequiredDescription
synthesizeYesSynthesize text into a complete audio buffer
synthesizeStreamNoSynthesize text as a stream of audio chunks

SynthesisOptions

PropertyTypeDescription
voicestringVoice identifier for the TTS engine
speednumberPlayback speed multiplier
formatAudioFormatDesired output audio format
sampleRatenumberDesired output sample rate in Hz

AudioSink Interface

interfaceAudioSink{play(audio: AudioData,segment: SegmentInfo): Promise<void>;pause?(): Promise<void>;resume?(): Promise<void>;stop?(): Promise<void>;}
MethodRequiredDescription
playYesPlay audio data for a given segment
pauseNoPause current playback
resumeNoResume paused playback
stopNoImmediately stop all playback

AudioData

PropertyTypeRequiredDescription
bufferBufferYesRaw audio bytes
formatAudioFormatYesAudio codec: 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'
sizeBytesnumberYesSize of the audio buffer in bytes
sampleRatenumberNoSample rate in Hz
channelsnumberNoNumber of audio channels
durationMsnumberNoDuration of the audio in milliseconds

SegmentInfo

PropertyTypeDescription
idstringUnique UUID for the segment
textstringSource text for the segment
indexnumber0-based position in the queue
stateSegmentStateCurrent lifecycle state
addedAtDateTimestamp when the segment was created
synthesisStartedAtDateTimestamp when synthesis began (optional)
synthesisCompletedAtDateTimestamp when synthesis completed (optional)
playbackStartedAtDateTimestamp when playback began (optional)
playbackCompletedAtDateTimestamp when playback completed (optional)
durationMsnumberAudio duration in milliseconds (optional)
errorErrorError that caused failure (optional)

Error Handling

All errors emitted by the queue are instances of TTSQueueError with a stage property indicating where the error originated.

Synthesis Errors

When a TTS provider's synthesize() call throws or rejects, the segment transitions to failed, a segment:error event is emitted with stage: 'synthesis', and the queue advances to the next segment. The queue does not stop.

queue.on('segment:error',(error,segment)=>{if(error.stage==='synthesis'){console.error(`Synthesis failed for "${segment.text}":`,error.cause);}});

Playback Errors

When the audio sink's play() method throws, the segment transitions to failed, a segment:error event is emitted with stage: 'playback', and the queue advances to the next segment.

Cancellation Errors

If a synthesis call is aborted via cancel(), the AbortController signal fires and the segment transitions to cancelled without emitting an error event. This is the expected path for interruptions.

Closed Queue

Calling push() or pushImmediate() on a closed queue throws a TTSQueueError with stage: 'internal' and the message "Queue is closed".

Invalid State Transitions

Attempting an invalid segment state transition (e.g., pending to played) throws a TTSQueueError with stage: 'internal'. This guards against programming errors in provider or sink implementations.


Advanced Usage

Priority Interruption

Replace the current playback with new content immediately:

// User asks a new question while the previous answer is playingawaitqueue.pushImmediate('Here is the answer to your new question.');// All pending/in-progress segments are cancelled, new text takes priority

Selective Cancellation

Cancel specific segments by ID while allowing others to continue:

constsegments=awaitqueue.push('Sentence one. Sentence two. Sentence three.');// Cancel only the last segmentawaitqueue.cancel([segments[2].id]);

Custom Sentence Splitting

Override the built-in splitter with your own logic:

constqueue=createQueue({
provider,
sink,splitting: {custom: (text)=>text.split(/\n\n/),// Split on double newlines},});

Event-Driven Progress Tracking

queue.on('segment:start',(segment)=>{console.log(`Synthesizing: "${segment.text}" (segment ${segment.index})`);});queue.on('segment:end',(segment)=>{console.log(`Finished: "${segment.text}" (${segment.durationMs}ms)`);});queue.on('state:change',(state)=>{console.log(`Queue state: ${state}`);});queue.on('queue:empty',()=>{console.log('All segments processed');});

Monitoring with Callbacks

constqueue=createQueue({
provider,
sink,onSegmentStart: (seg)=>metrics.trackSynthesisStart(seg.id),onSegmentEnd: (seg)=>metrics.trackSynthesisEnd(seg.id,seg.durationMs),onSegmentError: (err,seg)=>logger.error({ err,segmentId: seg.id}),onQueueEmpty: ()=>logger.info('Queue drained'),});

OpenAI TTS Provider Example

importOpenAIfrom'openai';importtype{TTSProvider,AudioData}from'tts-queue';constopenai=newOpenAI();constopenaiProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitopenai.audio.speech.create({model: 'tts-1',voice: 'alloy',input: text,response_format: 'mp3',});constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

ElevenLabs TTS Provider Example

importtype{TTSProvider,AudioData}from'tts-queue';constelevenLabsProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitfetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,{method: 'POST',headers: {'xi-api-key': process.env.ELEVENLABS_API_KEY!,'Content-Type': 'application/json',},body: JSON.stringify({ text,model_id: 'eleven_monolingual_v1'}),},);constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

Test Buffer Sink

Collect all audio data in memory for assertions:

importtype{AudioSink,AudioData,SegmentInfo}from'tts-queue';functioncreateTestSink(): AudioSink&{played: SegmentInfo[]}{constplayed: SegmentInfo[]=[];return{
played,asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{played.push(segment);},};}

TypeScript

tts-queue is written in TypeScript and ships with full type declarations (dist/index.d.ts). All exports are fully typed.

Exported Types

importtype{// Core interfacesTTSQueue,TTSProvider,AudioSink,QueueOptions,// Data typesAudioData,AudioChunk,AudioFormat,// 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'SynthesisOptions,// Segment typesSegmentInfo,SegmentState,// 'pending' | 'synthesizing' | 'synthesized' | 'playing'// | 'played' | 'cancelled' | 'failed'// Queue typesQueueState,// 'idle' | 'playing' | 'paused' | 'draining' | 'closed'QueueStats,CancelResult,TTSQueueEvents,// SplittingSplittingOptions,SplitOptions,// ErrorsTTSQueueStage,// 'synthesis' | 'playback' | 'splitting' | 'internal'}from'tts-queue';

Exported Values

import{createQueue,splitSentences,createSegment,transitionSegment,TTSQueueError,synthError,playbackError,splittingError,internalError,}from'tts-queue';

License

MIT

About

TTS audio streaming manager with sentence-boundary queuing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

tts-queue

TTS audio streaming manager with sentence-boundary queuing, gapless playback, and fast interruption handling.

npm versionnpm downloadslicensenodetypes


Description

tts-queue is the orchestration layer between text that needs to be spoken and audio that the user hears. It accepts text -- either as complete strings or as streaming token sequences from an LLM -- splits it into sentence-sized segments, sends each segment to a pluggable TTS provider for audio synthesis, and plays the resulting audio back-to-back through a pluggable audio sink.

The package handles every concern that sits between those two endpoints: sentence boundary detection (abbreviations, decimals, ellipsis, URLs), ordered FIFO playback queuing, per-segment lifecycle state management, cancellation of in-flight synthesis calls via AbortController, pause/resume, and error recovery. It replaces the 200-400 lines of async queue management, timer coordination, and provider-specific plumbing that every voice AI application otherwise implements from scratch.

Zero runtime dependencies. TypeScript-first. Provider-agnostic.


Installation

npm install tts-queue

Requires Node.js 18 or later.


Quick Start

import{createQueue}from'tts-queue';importtype{TTSProvider,AudioSink,AudioData,SegmentInfo}from'tts-queue';// 1. Define a TTS provider (wrap any TTS SDK)constprovider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaityourTTSClient.synthesize(text);return{buffer: response.audioBuffer,format: 'mp3',sizeBytes: response.audioBuffer.length,durationMs: response.durationMs,};},};// 2. Define an audio sink (where audio goes)constsink: AudioSink={asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{awaitspeaker.play(audio.buffer);},};// 3. Create the queue and push textconstqueue=createQueue({ provider, sink });awaitqueue.push('Hello there. How are you doing today? The weather is nice.');// Sentences are split, synthesized, and played in order automatically.// 4. Wait for all segments to finishawaitqueue.drain();// 5. Clean upawaitqueue.close();

Features

  • Sentence-boundary splitting -- Automatically segments text at sentence boundaries with handling for abbreviations (Dr., Mr., Mrs., etc.), decimal numbers, ellipsis, and URLs/domains.
  • Clause-boundary fallback -- Long sentences exceeding maxChars are split at semicolons, em dashes, and commas.
  • Short segment filtering -- Segments shorter than minChars are filtered to prevent choppy single-word synthesis.
  • Provider-agnostic -- Works with any TTS provider through a simple TTSProvider interface. Swap providers without rewriting queue logic.
  • Pluggable audio sinks -- Route audio to speakers, files, WebSockets, or test buffers via the AudioSink interface.
  • Ordered FIFO playback -- Segments play in strict sequential order.
  • Pause / Resume -- Suspend and resume playback without losing queue position. Calls through to sink pause() / resume() when available.
  • Fast interruption -- cancel() aborts all in-flight synthesis calls via AbortController and transitions the queue to idle.
  • Priority insertion -- pushImmediate() cancels pending segments and inserts new text at the front of the queue.
  • Per-segment lifecycle -- Each segment progresses through pending, synthesizing, synthesized, playing, played (or failed / cancelled) with timestamp tracking at every transition.
  • Typed events -- Subscribe to segment:start, segment:end, segment:error, queue:empty, queue:drain, and state:change events with full TypeScript typing.
  • Callback hooks -- Optional onSegmentStart, onSegmentEnd, onSegmentError, and onQueueEmpty callbacks on QueueOptions.
  • Queue statistics -- getStats() returns total, completed, failed, cancelled, and pending segment counts, total duration, and total characters processed.
  • Custom splitting -- Provide a custom splitting.custom function to override the built-in sentence splitter.
  • Zero runtime dependencies -- Only dev dependencies for TypeScript, ESLint, and Vitest.

API Reference

createQueue(options: QueueOptions): TTSQueue

Factory function that creates and returns a TTSQueue instance.

import{createQueue}from'tts-queue';constqueue=createQueue({
provider,// Required: TTSProvider
sink,// Required: AudioSinksplitting: {// Optional: splitting configurationmaxChars: 200,minChars: 10,on: 'sentence',custom: (text)=>text.split('\n'),},concurrency: 1,// Optional: concurrent synthesis limitprefetchCount: 2,// Optional: segments to pre-fetchonSegmentStart: (segment)=>{/* ... */},onSegmentEnd: (segment)=>{/* ... */},onSegmentError: (error,segment)=>{/* ... */},onQueueEmpty: ()=>{/* ... */},});

TTSQueue

The queue instance returned by createQueue. Implements the TTSQueue interface.

queue.push(text: string): Promise<SegmentInfo[]>

Split text into sentences and append segments to the queue. Returns the created SegmentInfo objects. Processing begins immediately.

constsegments=awaitqueue.push('First sentence. Second sentence. Third sentence.');console.log(segments.length);// 3

queue.pushImmediate(text: string): Promise<SegmentInfo[]>

Cancel all pending and in-progress segments, then insert new text at the front of the queue. Use this for interruption-and-replace patterns (e.g., the user asks a new question while the previous answer is still playing).

constsegments=awaitqueue.pushImmediate('Interrupting with new content.');

queue.pause(): Promise<void>

Pause playback. The queue transitions to paused state. If the sink implements pause(), it is called.

awaitqueue.pause();console.log(queue.getState());// 'paused'

queue.resume(): Promise<void>

Resume playback after a pause. The queue transitions back to playing state. If the sink implements resume(), it is called. Segment processing resumes automatically.

awaitqueue.resume();console.log(queue.getState());// 'playing'

queue.cancel(ids?: string[]): Promise<CancelResult>

Cancel segments. When called with no arguments, cancels all non-terminal segments, aborts in-flight synthesis via AbortController, and resets the queue to idle. When called with specific segment IDs, cancels only those segments.

// Cancel everythingconstresult=awaitqueue.cancel();console.log(result.cancelled);// number of segments cancelledconsole.log(result.ids);// IDs of cancelled segments// Cancel specific segmentsconstresult2=awaitqueue.cancel(['segment-id-1','segment-id-2']);

queue.drain(): Promise<void>

Wait for all segments to finish processing. Transitions the queue to draining state while segments complete, then resolves when the queue reaches idle.

awaitqueue.push('Some text to speak.');awaitqueue.drain();// resolves when all audio has finished playing

queue.close(): Promise<void>

Cancel all activity and permanently close the queue. Transitions to closed state. No more pushes are accepted after this call.

awaitqueue.close();console.log(queue.getState());// 'closed'

queue.getState(): QueueState

Returns the current queue state: 'idle', 'playing', 'paused', 'draining', or 'closed'.

queue.getStats(): QueueStats

Returns cumulative statistics for all segments processed by the queue.

conststats=queue.getStats();// {// totalSegments: 5,// completedSegments: 3,// failedSegments: 0,// cancelledSegments: 2,// pendingSegments: 0,// totalDurationMs: 4500,// totalChars: 312,// }

queue.getSegments(): SegmentInfo[]

Returns a snapshot (copy) of all segments and their current state.

queue.on<K>(event: K, listener: TTSQueueEvents[K]): void

Subscribe to a typed queue event.

queue.off<K>(event: K, listener: TTSQueueEvents[K]): void

Unsubscribe from a typed queue event.


Events

EventPayloadDescription
segment:startSegmentInfoFired when synthesis begins for a segment
segment:endSegmentInfoFired when a segment finishes playing
segment:errorTTSQueueError, SegmentInfoFired on synthesis or playback error
queue:empty--Fired when all segments have been played
queue:drain--Fired when the queue has been drained
state:changeQueueStateFired on every queue state transition

splitSentences(text: string, options?: SplitOptions): string[]

Standalone sentence splitter. Used internally by createQueue, but also exported for direct use.

import{splitSentences}from'tts-queue';splitSentences('Dr. Smith went to the store. She bought apples.');// ['Dr. Smith went to the store.', 'She bought apples.']splitSentences('Hello. World.',{minLength: 1});// ['Hello.', 'World.']splitSentences('');// []

SplitOptions

PropertyTypeDefaultDescription
minLengthnumber10Minimum segment length in characters. Shorter segments are filtered out.
maxLengthnumber200Maximum segment length. Longer segments are split at clause boundaries.
preserveWhitespacebooleanfalseWhen true, preserves leading/trailing whitespace in segments.

The built-in splitter handles:

  • Abbreviations: Mr., Mrs., Ms., Dr., Prof., St., Jr., Sr., vs., etc., e.g., i.e., Fig., Approx., Dept., Est., Govt., Inc., Corp., Ltd., Co., U.S., U.K., U.N.
  • Decimal numbers: 98.6, 3.14, $9.99 -- periods between digits are not treated as boundaries.
  • Ellipsis: ... -- consecutive periods are not treated as boundaries.
  • URLs and domains: example.com -- periods followed immediately by a letter or digit (no space) are not treated as boundaries.
  • Single-letter initials: A. B. Smith -- single uppercase letters followed by a period are not treated as boundaries.
  • Quoted strings: Sentence boundaries inside quoted strings (double quotes, smart quotes) are ignored.
  • Long sentence fallback: Sentences exceeding maxLength are split at semicolons, em dashes (---, unicode em dash), then commas (only when both halves meet minLength).

createSegment(text: string, index: number): SegmentInfo

Create a new segment in pending state with a unique UUID, timestamp, and the given text and index.

import{createSegment}from'tts-queue';constsegment=createSegment('Hello world.',0);// { id: 'uuid', text: 'Hello world.', index: 0, state: 'pending', addedAt: Date }

transitionSegment(segment: SegmentInfo, newState: SegmentState, extra?: Partial<SegmentInfo>): SegmentInfo

Immutably transition a segment to a new state. Enforces the valid state transition graph and sets appropriate timestamps (synthesisStartedAt, synthesisCompletedAt, playbackStartedAt, playbackCompletedAt). Throws TTSQueueError on invalid transitions. The original segment object is never mutated.

Valid transitions:

pending -> synthesizing | cancelled
synthesizing -> synthesized | failed | cancelled
synthesized -> playing | cancelled
playing -> played | failed | cancelled
played -> (terminal)
failed -> (terminal)
cancelled -> (terminal)

TTSQueueError

Custom error class for all errors originating from the queue. Extends Error with additional context fields.

import{TTSQueueError}from'tts-queue';try{// ...}catch(err){if(errinstanceofTTSQueueError){console.log(err.name);// 'TTSQueueError'console.log(err.stage);// 'synthesis' | 'playback' | 'splitting' | 'internal'console.log(err.cause);// underlying Error, if anyconsole.log(err.segment);// SegmentInfo, if associated with a segment}}

Error Factory Functions

FunctionStageDescription
synthErrorsynthesisTTS provider synthesis failure
playbackErrorplaybackAudio sink playback failure
splittingErrorsplittingText splitting failure
internalErrorinternalQueue internal error (invalid state)

Each factory accepts (message: string, cause?: Error, segment?: SegmentInfo) and returns a TTSQueueError.

import{synthError}from'tts-queue';consterr=synthError('Provider timeout',newError('ETIMEDOUT'),segmentInfo);

Configuration

QueueOptions

PropertyTypeRequiredDefaultDescription
providerTTSProviderYes--TTS synthesis provider
sinkAudioSinkYes--Audio output destination
splittingSplittingOptionsNo{}Sentence splitting configuration
concurrencynumberNo1Maximum concurrent synthesis calls
prefetchCountnumberNo2Number of segments to pre-fetch ahead of playback
onSegmentStart(segment: SegmentInfo) => voidNo--Callback when a segment begins synthesis
onSegmentEnd(segment: SegmentInfo) => voidNo--Callback when a segment finishes playback
onSegmentError(error: TTSQueueError, segment) => voidNo--Callback on segment-level errors
onQueueEmpty() => voidNo--Callback when all segments have completed

SplittingOptions

PropertyTypeDefaultDescription
maxCharsnumber200Maximum segment length before clause-boundary split
minCharsnumber10Minimum segment length; shorter segments are filtered
on'sentence' | 'word' | 'paragraph''sentence'Splitting strategy hint
custom(text: string) => string[]--Custom splitter function; overrides built-in logic

TTSProvider Interface

interfaceTTSProvider{synthesize(text: string,options?: SynthesisOptions): Promise<AudioData>;synthesizeStream?(text: string,options?: SynthesisOptions): AsyncIterable<AudioChunk>;}
MethodRequiredDescription
synthesizeYesSynthesize text into a complete audio buffer
synthesizeStreamNoSynthesize text as a stream of audio chunks

SynthesisOptions

PropertyTypeDescription
voicestringVoice identifier for the TTS engine
speednumberPlayback speed multiplier
formatAudioFormatDesired output audio format
sampleRatenumberDesired output sample rate in Hz

AudioSink Interface

interfaceAudioSink{play(audio: AudioData,segment: SegmentInfo): Promise<void>;pause?(): Promise<void>;resume?(): Promise<void>;stop?(): Promise<void>;}
MethodRequiredDescription
playYesPlay audio data for a given segment
pauseNoPause current playback
resumeNoResume paused playback
stopNoImmediately stop all playback

AudioData

PropertyTypeRequiredDescription
bufferBufferYesRaw audio bytes
formatAudioFormatYesAudio codec: 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'
sizeBytesnumberYesSize of the audio buffer in bytes
sampleRatenumberNoSample rate in Hz
channelsnumberNoNumber of audio channels
durationMsnumberNoDuration of the audio in milliseconds

SegmentInfo

PropertyTypeDescription
idstringUnique UUID for the segment
textstringSource text for the segment
indexnumber0-based position in the queue
stateSegmentStateCurrent lifecycle state
addedAtDateTimestamp when the segment was created
synthesisStartedAtDateTimestamp when synthesis began (optional)
synthesisCompletedAtDateTimestamp when synthesis completed (optional)
playbackStartedAtDateTimestamp when playback began (optional)
playbackCompletedAtDateTimestamp when playback completed (optional)
durationMsnumberAudio duration in milliseconds (optional)
errorErrorError that caused failure (optional)

Error Handling

All errors emitted by the queue are instances of TTSQueueError with a stage property indicating where the error originated.

Synthesis Errors

When a TTS provider's synthesize() call throws or rejects, the segment transitions to failed, a segment:error event is emitted with stage: 'synthesis', and the queue advances to the next segment. The queue does not stop.

queue.on('segment:error',(error,segment)=>{if(error.stage==='synthesis'){console.error(`Synthesis failed for "${segment.text}":`,error.cause);}});

Playback Errors

When the audio sink's play() method throws, the segment transitions to failed, a segment:error event is emitted with stage: 'playback', and the queue advances to the next segment.

Cancellation Errors

If a synthesis call is aborted via cancel(), the AbortController signal fires and the segment transitions to cancelled without emitting an error event. This is the expected path for interruptions.

Closed Queue

Calling push() or pushImmediate() on a closed queue throws a TTSQueueError with stage: 'internal' and the message "Queue is closed".

Invalid State Transitions

Attempting an invalid segment state transition (e.g., pending to played) throws a TTSQueueError with stage: 'internal'. This guards against programming errors in provider or sink implementations.


Advanced Usage

Priority Interruption

Replace the current playback with new content immediately:

// User asks a new question while the previous answer is playingawaitqueue.pushImmediate('Here is the answer to your new question.');// All pending/in-progress segments are cancelled, new text takes priority

Selective Cancellation

Cancel specific segments by ID while allowing others to continue:

constsegments=awaitqueue.push('Sentence one. Sentence two. Sentence three.');// Cancel only the last segmentawaitqueue.cancel([segments[2].id]);

Custom Sentence Splitting

Override the built-in splitter with your own logic:

constqueue=createQueue({
provider,
sink,splitting: {custom: (text)=>text.split(/\n\n/),// Split on double newlines},});

Event-Driven Progress Tracking

queue.on('segment:start',(segment)=>{console.log(`Synthesizing: "${segment.text}" (segment ${segment.index})`);});queue.on('segment:end',(segment)=>{console.log(`Finished: "${segment.text}" (${segment.durationMs}ms)`);});queue.on('state:change',(state)=>{console.log(`Queue state: ${state}`);});queue.on('queue:empty',()=>{console.log('All segments processed');});

Monitoring with Callbacks

constqueue=createQueue({
provider,
sink,onSegmentStart: (seg)=>metrics.trackSynthesisStart(seg.id),onSegmentEnd: (seg)=>metrics.trackSynthesisEnd(seg.id,seg.durationMs),onSegmentError: (err,seg)=>logger.error({ err,segmentId: seg.id}),onQueueEmpty: ()=>logger.info('Queue drained'),});

OpenAI TTS Provider Example

importOpenAIfrom'openai';importtype{TTSProvider,AudioData}from'tts-queue';constopenai=newOpenAI();constopenaiProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitopenai.audio.speech.create({model: 'tts-1',voice: 'alloy',input: text,response_format: 'mp3',});constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

ElevenLabs TTS Provider Example

importtype{TTSProvider,AudioData}from'tts-queue';constelevenLabsProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitfetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,{method: 'POST',headers: {'xi-api-key': process.env.ELEVENLABS_API_KEY!,'Content-Type': 'application/json',},body: JSON.stringify({ text,model_id: 'eleven_monolingual_v1'}),},);constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

Test Buffer Sink

Collect all audio data in memory for assertions:

importtype{AudioSink,AudioData,SegmentInfo}from'tts-queue';functioncreateTestSink(): AudioSink&{played: SegmentInfo[]}{constplayed: SegmentInfo[]=[];return{
played,asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{played.push(segment);},};}

TypeScript

tts-queue is written in TypeScript and ships with full type declarations (dist/index.d.ts). All exports are fully typed.

Exported Types

importtype{// Core interfacesTTSQueue,TTSProvider,AudioSink,QueueOptions,// Data typesAudioData,AudioChunk,AudioFormat,// 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'SynthesisOptions,// Segment typesSegmentInfo,SegmentState,// 'pending' | 'synthesizing' | 'synthesized' | 'playing'// | 'played' | 'cancelled' | 'failed'// Queue typesQueueState,// 'idle' | 'playing' | 'paused' | 'draining' | 'closed'QueueStats,CancelResult,TTSQueueEvents,// SplittingSplittingOptions,SplitOptions,// ErrorsTTSQueueStage,// 'synthesis' | 'playback' | 'splitting' | 'internal'}from'tts-queue';

Exported Values

import{createQueue,splitSentences,createSegment,transitionSegment,TTSQueueError,synthError,playbackError,splittingError,internalError,}from'tts-queue';

License

MIT

About

TTS audio streaming manager with sentence-boundary queuing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

tts-queue

TTS audio streaming manager with sentence-boundary queuing, gapless playback, and fast interruption handling.

npm versionnpm downloadslicensenodetypes


Description

tts-queue is the orchestration layer between text that needs to be spoken and audio that the user hears. It accepts text -- either as complete strings or as streaming token sequences from an LLM -- splits it into sentence-sized segments, sends each segment to a pluggable TTS provider for audio synthesis, and plays the resulting audio back-to-back through a pluggable audio sink.

The package handles every concern that sits between those two endpoints: sentence boundary detection (abbreviations, decimals, ellipsis, URLs), ordered FIFO playback queuing, per-segment lifecycle state management, cancellation of in-flight synthesis calls via AbortController, pause/resume, and error recovery. It replaces the 200-400 lines of async queue management, timer coordination, and provider-specific plumbing that every voice AI application otherwise implements from scratch.

Zero runtime dependencies. TypeScript-first. Provider-agnostic.


Installation

npm install tts-queue

Requires Node.js 18 or later.


Quick Start

import{createQueue}from'tts-queue';importtype{TTSProvider,AudioSink,AudioData,SegmentInfo}from'tts-queue';// 1. Define a TTS provider (wrap any TTS SDK)constprovider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaityourTTSClient.synthesize(text);return{buffer: response.audioBuffer,format: 'mp3',sizeBytes: response.audioBuffer.length,durationMs: response.durationMs,};},};// 2. Define an audio sink (where audio goes)constsink: AudioSink={asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{awaitspeaker.play(audio.buffer);},};// 3. Create the queue and push textconstqueue=createQueue({ provider, sink });awaitqueue.push('Hello there. How are you doing today? The weather is nice.');// Sentences are split, synthesized, and played in order automatically.// 4. Wait for all segments to finishawaitqueue.drain();// 5. Clean upawaitqueue.close();

Features

  • Sentence-boundary splitting -- Automatically segments text at sentence boundaries with handling for abbreviations (Dr., Mr., Mrs., etc.), decimal numbers, ellipsis, and URLs/domains.
  • Clause-boundary fallback -- Long sentences exceeding maxChars are split at semicolons, em dashes, and commas.
  • Short segment filtering -- Segments shorter than minChars are filtered to prevent choppy single-word synthesis.
  • Provider-agnostic -- Works with any TTS provider through a simple TTSProvider interface. Swap providers without rewriting queue logic.
  • Pluggable audio sinks -- Route audio to speakers, files, WebSockets, or test buffers via the AudioSink interface.
  • Ordered FIFO playback -- Segments play in strict sequential order.
  • Pause / Resume -- Suspend and resume playback without losing queue position. Calls through to sink pause() / resume() when available.
  • Fast interruption -- cancel() aborts all in-flight synthesis calls via AbortController and transitions the queue to idle.
  • Priority insertion -- pushImmediate() cancels pending segments and inserts new text at the front of the queue.
  • Per-segment lifecycle -- Each segment progresses through pending, synthesizing, synthesized, playing, played (or failed / cancelled) with timestamp tracking at every transition.
  • Typed events -- Subscribe to segment:start, segment:end, segment:error, queue:empty, queue:drain, and state:change events with full TypeScript typing.
  • Callback hooks -- Optional onSegmentStart, onSegmentEnd, onSegmentError, and onQueueEmpty callbacks on QueueOptions.
  • Queue statistics -- getStats() returns total, completed, failed, cancelled, and pending segment counts, total duration, and total characters processed.
  • Custom splitting -- Provide a custom splitting.custom function to override the built-in sentence splitter.
  • Zero runtime dependencies -- Only dev dependencies for TypeScript, ESLint, and Vitest.

API Reference

createQueue(options: QueueOptions): TTSQueue

Factory function that creates and returns a TTSQueue instance.

import{createQueue}from'tts-queue';constqueue=createQueue({
provider,// Required: TTSProvider
sink,// Required: AudioSinksplitting: {// Optional: splitting configurationmaxChars: 200,minChars: 10,on: 'sentence',custom: (text)=>text.split('\n'),},concurrency: 1,// Optional: concurrent synthesis limitprefetchCount: 2,// Optional: segments to pre-fetchonSegmentStart: (segment)=>{/* ... */},onSegmentEnd: (segment)=>{/* ... */},onSegmentError: (error,segment)=>{/* ... */},onQueueEmpty: ()=>{/* ... */},});

TTSQueue

The queue instance returned by createQueue. Implements the TTSQueue interface.

queue.push(text: string): Promise<SegmentInfo[]>

Split text into sentences and append segments to the queue. Returns the created SegmentInfo objects. Processing begins immediately.

constsegments=awaitqueue.push('First sentence. Second sentence. Third sentence.');console.log(segments.length);// 3

queue.pushImmediate(text: string): Promise<SegmentInfo[]>

Cancel all pending and in-progress segments, then insert new text at the front of the queue. Use this for interruption-and-replace patterns (e.g., the user asks a new question while the previous answer is still playing).

constsegments=awaitqueue.pushImmediate('Interrupting with new content.');

queue.pause(): Promise<void>

Pause playback. The queue transitions to paused state. If the sink implements pause(), it is called.

awaitqueue.pause();console.log(queue.getState());// 'paused'

queue.resume(): Promise<void>

Resume playback after a pause. The queue transitions back to playing state. If the sink implements resume(), it is called. Segment processing resumes automatically.

awaitqueue.resume();console.log(queue.getState());// 'playing'

queue.cancel(ids?: string[]): Promise<CancelResult>

Cancel segments. When called with no arguments, cancels all non-terminal segments, aborts in-flight synthesis via AbortController, and resets the queue to idle. When called with specific segment IDs, cancels only those segments.

// Cancel everythingconstresult=awaitqueue.cancel();console.log(result.cancelled);// number of segments cancelledconsole.log(result.ids);// IDs of cancelled segments// Cancel specific segmentsconstresult2=awaitqueue.cancel(['segment-id-1','segment-id-2']);

queue.drain(): Promise<void>

Wait for all segments to finish processing. Transitions the queue to draining state while segments complete, then resolves when the queue reaches idle.

awaitqueue.push('Some text to speak.');awaitqueue.drain();// resolves when all audio has finished playing

queue.close(): Promise<void>

Cancel all activity and permanently close the queue. Transitions to closed state. No more pushes are accepted after this call.

awaitqueue.close();console.log(queue.getState());// 'closed'

queue.getState(): QueueState

Returns the current queue state: 'idle', 'playing', 'paused', 'draining', or 'closed'.

queue.getStats(): QueueStats

Returns cumulative statistics for all segments processed by the queue.

conststats=queue.getStats();// {// totalSegments: 5,// completedSegments: 3,// failedSegments: 0,// cancelledSegments: 2,// pendingSegments: 0,// totalDurationMs: 4500,// totalChars: 312,// }

queue.getSegments(): SegmentInfo[]

Returns a snapshot (copy) of all segments and their current state.

queue.on<K>(event: K, listener: TTSQueueEvents[K]): void

Subscribe to a typed queue event.

queue.off<K>(event: K, listener: TTSQueueEvents[K]): void

Unsubscribe from a typed queue event.


Events

EventPayloadDescription
segment:startSegmentInfoFired when synthesis begins for a segment
segment:endSegmentInfoFired when a segment finishes playing
segment:errorTTSQueueError, SegmentInfoFired on synthesis or playback error
queue:empty--Fired when all segments have been played
queue:drain--Fired when the queue has been drained
state:changeQueueStateFired on every queue state transition

splitSentences(text: string, options?: SplitOptions): string[]

Standalone sentence splitter. Used internally by createQueue, but also exported for direct use.

import{splitSentences}from'tts-queue';splitSentences('Dr. Smith went to the store. She bought apples.');// ['Dr. Smith went to the store.', 'She bought apples.']splitSentences('Hello. World.',{minLength: 1});// ['Hello.', 'World.']splitSentences('');// []

SplitOptions

PropertyTypeDefaultDescription
minLengthnumber10Minimum segment length in characters. Shorter segments are filtered out.
maxLengthnumber200Maximum segment length. Longer segments are split at clause boundaries.
preserveWhitespacebooleanfalseWhen true, preserves leading/trailing whitespace in segments.

The built-in splitter handles:

  • Abbreviations: Mr., Mrs., Ms., Dr., Prof., St., Jr., Sr., vs., etc., e.g., i.e., Fig., Approx., Dept., Est., Govt., Inc., Corp., Ltd., Co., U.S., U.K., U.N.
  • Decimal numbers: 98.6, 3.14, $9.99 -- periods between digits are not treated as boundaries.
  • Ellipsis: ... -- consecutive periods are not treated as boundaries.
  • URLs and domains: example.com -- periods followed immediately by a letter or digit (no space) are not treated as boundaries.
  • Single-letter initials: A. B. Smith -- single uppercase letters followed by a period are not treated as boundaries.
  • Quoted strings: Sentence boundaries inside quoted strings (double quotes, smart quotes) are ignored.
  • Long sentence fallback: Sentences exceeding maxLength are split at semicolons, em dashes (---, unicode em dash), then commas (only when both halves meet minLength).

createSegment(text: string, index: number): SegmentInfo

Create a new segment in pending state with a unique UUID, timestamp, and the given text and index.

import{createSegment}from'tts-queue';constsegment=createSegment('Hello world.',0);// { id: 'uuid', text: 'Hello world.', index: 0, state: 'pending', addedAt: Date }

transitionSegment(segment: SegmentInfo, newState: SegmentState, extra?: Partial<SegmentInfo>): SegmentInfo

Immutably transition a segment to a new state. Enforces the valid state transition graph and sets appropriate timestamps (synthesisStartedAt, synthesisCompletedAt, playbackStartedAt, playbackCompletedAt). Throws TTSQueueError on invalid transitions. The original segment object is never mutated.

Valid transitions:

pending -> synthesizing | cancelled
synthesizing -> synthesized | failed | cancelled
synthesized -> playing | cancelled
playing -> played | failed | cancelled
played -> (terminal)
failed -> (terminal)
cancelled -> (terminal)

TTSQueueError

Custom error class for all errors originating from the queue. Extends Error with additional context fields.

import{TTSQueueError}from'tts-queue';try{// ...}catch(err){if(errinstanceofTTSQueueError){console.log(err.name);// 'TTSQueueError'console.log(err.stage);// 'synthesis' | 'playback' | 'splitting' | 'internal'console.log(err.cause);// underlying Error, if anyconsole.log(err.segment);// SegmentInfo, if associated with a segment}}

Error Factory Functions

FunctionStageDescription
synthErrorsynthesisTTS provider synthesis failure
playbackErrorplaybackAudio sink playback failure
splittingErrorsplittingText splitting failure
internalErrorinternalQueue internal error (invalid state)

Each factory accepts (message: string, cause?: Error, segment?: SegmentInfo) and returns a TTSQueueError.

import{synthError}from'tts-queue';consterr=synthError('Provider timeout',newError('ETIMEDOUT'),segmentInfo);

Configuration

QueueOptions

PropertyTypeRequiredDefaultDescription
providerTTSProviderYes--TTS synthesis provider
sinkAudioSinkYes--Audio output destination
splittingSplittingOptionsNo{}Sentence splitting configuration
concurrencynumberNo1Maximum concurrent synthesis calls
prefetchCountnumberNo2Number of segments to pre-fetch ahead of playback
onSegmentStart(segment: SegmentInfo) => voidNo--Callback when a segment begins synthesis
onSegmentEnd(segment: SegmentInfo) => voidNo--Callback when a segment finishes playback
onSegmentError(error: TTSQueueError, segment) => voidNo--Callback on segment-level errors
onQueueEmpty() => voidNo--Callback when all segments have completed

SplittingOptions

PropertyTypeDefaultDescription
maxCharsnumber200Maximum segment length before clause-boundary split
minCharsnumber10Minimum segment length; shorter segments are filtered
on'sentence' | 'word' | 'paragraph''sentence'Splitting strategy hint
custom(text: string) => string[]--Custom splitter function; overrides built-in logic

TTSProvider Interface

interfaceTTSProvider{synthesize(text: string,options?: SynthesisOptions): Promise<AudioData>;synthesizeStream?(text: string,options?: SynthesisOptions): AsyncIterable<AudioChunk>;}
MethodRequiredDescription
synthesizeYesSynthesize text into a complete audio buffer
synthesizeStreamNoSynthesize text as a stream of audio chunks

SynthesisOptions

PropertyTypeDescription
voicestringVoice identifier for the TTS engine
speednumberPlayback speed multiplier
formatAudioFormatDesired output audio format
sampleRatenumberDesired output sample rate in Hz

AudioSink Interface

interfaceAudioSink{play(audio: AudioData,segment: SegmentInfo): Promise<void>;pause?(): Promise<void>;resume?(): Promise<void>;stop?(): Promise<void>;}
MethodRequiredDescription
playYesPlay audio data for a given segment
pauseNoPause current playback
resumeNoResume paused playback
stopNoImmediately stop all playback

AudioData

PropertyTypeRequiredDescription
bufferBufferYesRaw audio bytes
formatAudioFormatYesAudio codec: 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'
sizeBytesnumberYesSize of the audio buffer in bytes
sampleRatenumberNoSample rate in Hz
channelsnumberNoNumber of audio channels
durationMsnumberNoDuration of the audio in milliseconds

SegmentInfo

PropertyTypeDescription
idstringUnique UUID for the segment
textstringSource text for the segment
indexnumber0-based position in the queue
stateSegmentStateCurrent lifecycle state
addedAtDateTimestamp when the segment was created
synthesisStartedAtDateTimestamp when synthesis began (optional)
synthesisCompletedAtDateTimestamp when synthesis completed (optional)
playbackStartedAtDateTimestamp when playback began (optional)
playbackCompletedAtDateTimestamp when playback completed (optional)
durationMsnumberAudio duration in milliseconds (optional)
errorErrorError that caused failure (optional)

Error Handling

All errors emitted by the queue are instances of TTSQueueError with a stage property indicating where the error originated.

Synthesis Errors

When a TTS provider's synthesize() call throws or rejects, the segment transitions to failed, a segment:error event is emitted with stage: 'synthesis', and the queue advances to the next segment. The queue does not stop.

queue.on('segment:error',(error,segment)=>{if(error.stage==='synthesis'){console.error(`Synthesis failed for "${segment.text}":`,error.cause);}});

Playback Errors

When the audio sink's play() method throws, the segment transitions to failed, a segment:error event is emitted with stage: 'playback', and the queue advances to the next segment.

Cancellation Errors

If a synthesis call is aborted via cancel(), the AbortController signal fires and the segment transitions to cancelled without emitting an error event. This is the expected path for interruptions.

Closed Queue

Calling push() or pushImmediate() on a closed queue throws a TTSQueueError with stage: 'internal' and the message "Queue is closed".

Invalid State Transitions

Attempting an invalid segment state transition (e.g., pending to played) throws a TTSQueueError with stage: 'internal'. This guards against programming errors in provider or sink implementations.


Advanced Usage

Priority Interruption

Replace the current playback with new content immediately:

// User asks a new question while the previous answer is playingawaitqueue.pushImmediate('Here is the answer to your new question.');// All pending/in-progress segments are cancelled, new text takes priority

Selective Cancellation

Cancel specific segments by ID while allowing others to continue:

constsegments=awaitqueue.push('Sentence one. Sentence two. Sentence three.');// Cancel only the last segmentawaitqueue.cancel([segments[2].id]);

Custom Sentence Splitting

Override the built-in splitter with your own logic:

constqueue=createQueue({
provider,
sink,splitting: {custom: (text)=>text.split(/\n\n/),// Split on double newlines},});

Event-Driven Progress Tracking

queue.on('segment:start',(segment)=>{console.log(`Synthesizing: "${segment.text}" (segment ${segment.index})`);});queue.on('segment:end',(segment)=>{console.log(`Finished: "${segment.text}" (${segment.durationMs}ms)`);});queue.on('state:change',(state)=>{console.log(`Queue state: ${state}`);});queue.on('queue:empty',()=>{console.log('All segments processed');});

Monitoring with Callbacks

constqueue=createQueue({
provider,
sink,onSegmentStart: (seg)=>metrics.trackSynthesisStart(seg.id),onSegmentEnd: (seg)=>metrics.trackSynthesisEnd(seg.id,seg.durationMs),onSegmentError: (err,seg)=>logger.error({ err,segmentId: seg.id}),onQueueEmpty: ()=>logger.info('Queue drained'),});

OpenAI TTS Provider Example

importOpenAIfrom'openai';importtype{TTSProvider,AudioData}from'tts-queue';constopenai=newOpenAI();constopenaiProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitopenai.audio.speech.create({model: 'tts-1',voice: 'alloy',input: text,response_format: 'mp3',});constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

ElevenLabs TTS Provider Example

importtype{TTSProvider,AudioData}from'tts-queue';constelevenLabsProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitfetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,{method: 'POST',headers: {'xi-api-key': process.env.ELEVENLABS_API_KEY!,'Content-Type': 'application/json',},body: JSON.stringify({ text,model_id: 'eleven_monolingual_v1'}),},);constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

Test Buffer Sink

Collect all audio data in memory for assertions:

importtype{AudioSink,AudioData,SegmentInfo}from'tts-queue';functioncreateTestSink(): AudioSink&{played: SegmentInfo[]}{constplayed: SegmentInfo[]=[];return{
played,asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{played.push(segment);},};}

TypeScript

tts-queue is written in TypeScript and ships with full type declarations (dist/index.d.ts). All exports are fully typed.

Exported Types

importtype{// Core interfacesTTSQueue,TTSProvider,AudioSink,QueueOptions,// Data typesAudioData,AudioChunk,AudioFormat,// 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'SynthesisOptions,// Segment typesSegmentInfo,SegmentState,// 'pending' | 'synthesizing' | 'synthesized' | 'playing'// | 'played' | 'cancelled' | 'failed'// Queue typesQueueState,// 'idle' | 'playing' | 'paused' | 'draining' | 'closed'QueueStats,CancelResult,TTSQueueEvents,// SplittingSplittingOptions,SplitOptions,// ErrorsTTSQueueStage,// 'synthesis' | 'playback' | 'splitting' | 'internal'}from'tts-queue';

Exported Values

import{createQueue,splitSentences,createSegment,transitionSegment,TTSQueueError,synthError,playbackError,splittingError,internalError,}from'tts-queue';

License

MIT

About

TTS audio streaming manager with sentence-boundary queuing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

tts-queue

TTS audio streaming manager with sentence-boundary queuing, gapless playback, and fast interruption handling.

npm versionnpm downloadslicensenodetypes


Description

tts-queue is the orchestration layer between text that needs to be spoken and audio that the user hears. It accepts text -- either as complete strings or as streaming token sequences from an LLM -- splits it into sentence-sized segments, sends each segment to a pluggable TTS provider for audio synthesis, and plays the resulting audio back-to-back through a pluggable audio sink.

The package handles every concern that sits between those two endpoints: sentence boundary detection (abbreviations, decimals, ellipsis, URLs), ordered FIFO playback queuing, per-segment lifecycle state management, cancellation of in-flight synthesis calls via AbortController, pause/resume, and error recovery. It replaces the 200-400 lines of async queue management, timer coordination, and provider-specific plumbing that every voice AI application otherwise implements from scratch.

Zero runtime dependencies. TypeScript-first. Provider-agnostic.


Installation

npm install tts-queue

Requires Node.js 18 or later.


Quick Start

import{createQueue}from'tts-queue';importtype{TTSProvider,AudioSink,AudioData,SegmentInfo}from'tts-queue';// 1. Define a TTS provider (wrap any TTS SDK)constprovider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaityourTTSClient.synthesize(text);return{buffer: response.audioBuffer,format: 'mp3',sizeBytes: response.audioBuffer.length,durationMs: response.durationMs,};},};// 2. Define an audio sink (where audio goes)constsink: AudioSink={asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{awaitspeaker.play(audio.buffer);},};// 3. Create the queue and push textconstqueue=createQueue({ provider, sink });awaitqueue.push('Hello there. How are you doing today? The weather is nice.');// Sentences are split, synthesized, and played in order automatically.// 4. Wait for all segments to finishawaitqueue.drain();// 5. Clean upawaitqueue.close();

Features

  • Sentence-boundary splitting -- Automatically segments text at sentence boundaries with handling for abbreviations (Dr., Mr., Mrs., etc.), decimal numbers, ellipsis, and URLs/domains.
  • Clause-boundary fallback -- Long sentences exceeding maxChars are split at semicolons, em dashes, and commas.
  • Short segment filtering -- Segments shorter than minChars are filtered to prevent choppy single-word synthesis.
  • Provider-agnostic -- Works with any TTS provider through a simple TTSProvider interface. Swap providers without rewriting queue logic.
  • Pluggable audio sinks -- Route audio to speakers, files, WebSockets, or test buffers via the AudioSink interface.
  • Ordered FIFO playback -- Segments play in strict sequential order.
  • Pause / Resume -- Suspend and resume playback without losing queue position. Calls through to sink pause() / resume() when available.
  • Fast interruption -- cancel() aborts all in-flight synthesis calls via AbortController and transitions the queue to idle.
  • Priority insertion -- pushImmediate() cancels pending segments and inserts new text at the front of the queue.
  • Per-segment lifecycle -- Each segment progresses through pending, synthesizing, synthesized, playing, played (or failed / cancelled) with timestamp tracking at every transition.
  • Typed events -- Subscribe to segment:start, segment:end, segment:error, queue:empty, queue:drain, and state:change events with full TypeScript typing.
  • Callback hooks -- Optional onSegmentStart, onSegmentEnd, onSegmentError, and onQueueEmpty callbacks on QueueOptions.
  • Queue statistics -- getStats() returns total, completed, failed, cancelled, and pending segment counts, total duration, and total characters processed.
  • Custom splitting -- Provide a custom splitting.custom function to override the built-in sentence splitter.
  • Zero runtime dependencies -- Only dev dependencies for TypeScript, ESLint, and Vitest.

API Reference

createQueue(options: QueueOptions): TTSQueue

Factory function that creates and returns a TTSQueue instance.

import{createQueue}from'tts-queue';constqueue=createQueue({
provider,// Required: TTSProvider
sink,// Required: AudioSinksplitting: {// Optional: splitting configurationmaxChars: 200,minChars: 10,on: 'sentence',custom: (text)=>text.split('\n'),},concurrency: 1,// Optional: concurrent synthesis limitprefetchCount: 2,// Optional: segments to pre-fetchonSegmentStart: (segment)=>{/* ... */},onSegmentEnd: (segment)=>{/* ... */},onSegmentError: (error,segment)=>{/* ... */},onQueueEmpty: ()=>{/* ... */},});

TTSQueue

The queue instance returned by createQueue. Implements the TTSQueue interface.

queue.push(text: string): Promise<SegmentInfo[]>

Split text into sentences and append segments to the queue. Returns the created SegmentInfo objects. Processing begins immediately.

constsegments=awaitqueue.push('First sentence. Second sentence. Third sentence.');console.log(segments.length);// 3

queue.pushImmediate(text: string): Promise<SegmentInfo[]>

Cancel all pending and in-progress segments, then insert new text at the front of the queue. Use this for interruption-and-replace patterns (e.g., the user asks a new question while the previous answer is still playing).

constsegments=awaitqueue.pushImmediate('Interrupting with new content.');

queue.pause(): Promise<void>

Pause playback. The queue transitions to paused state. If the sink implements pause(), it is called.

awaitqueue.pause();console.log(queue.getState());// 'paused'

queue.resume(): Promise<void>

Resume playback after a pause. The queue transitions back to playing state. If the sink implements resume(), it is called. Segment processing resumes automatically.

awaitqueue.resume();console.log(queue.getState());// 'playing'

queue.cancel(ids?: string[]): Promise<CancelResult>

Cancel segments. When called with no arguments, cancels all non-terminal segments, aborts in-flight synthesis via AbortController, and resets the queue to idle. When called with specific segment IDs, cancels only those segments.

// Cancel everythingconstresult=awaitqueue.cancel();console.log(result.cancelled);// number of segments cancelledconsole.log(result.ids);// IDs of cancelled segments// Cancel specific segmentsconstresult2=awaitqueue.cancel(['segment-id-1','segment-id-2']);

queue.drain(): Promise<void>

Wait for all segments to finish processing. Transitions the queue to draining state while segments complete, then resolves when the queue reaches idle.

awaitqueue.push('Some text to speak.');awaitqueue.drain();// resolves when all audio has finished playing

queue.close(): Promise<void>

Cancel all activity and permanently close the queue. Transitions to closed state. No more pushes are accepted after this call.

awaitqueue.close();console.log(queue.getState());// 'closed'

queue.getState(): QueueState

Returns the current queue state: 'idle', 'playing', 'paused', 'draining', or 'closed'.

queue.getStats(): QueueStats

Returns cumulative statistics for all segments processed by the queue.

conststats=queue.getStats();// {// totalSegments: 5,// completedSegments: 3,// failedSegments: 0,// cancelledSegments: 2,// pendingSegments: 0,// totalDurationMs: 4500,// totalChars: 312,// }

queue.getSegments(): SegmentInfo[]

Returns a snapshot (copy) of all segments and their current state.

queue.on<K>(event: K, listener: TTSQueueEvents[K]): void

Subscribe to a typed queue event.

queue.off<K>(event: K, listener: TTSQueueEvents[K]): void

Unsubscribe from a typed queue event.


Events

EventPayloadDescription
segment:startSegmentInfoFired when synthesis begins for a segment
segment:endSegmentInfoFired when a segment finishes playing
segment:errorTTSQueueError, SegmentInfoFired on synthesis or playback error
queue:empty--Fired when all segments have been played
queue:drain--Fired when the queue has been drained
state:changeQueueStateFired on every queue state transition

splitSentences(text: string, options?: SplitOptions): string[]

Standalone sentence splitter. Used internally by createQueue, but also exported for direct use.

import{splitSentences}from'tts-queue';splitSentences('Dr. Smith went to the store. She bought apples.');// ['Dr. Smith went to the store.', 'She bought apples.']splitSentences('Hello. World.',{minLength: 1});// ['Hello.', 'World.']splitSentences('');// []

SplitOptions

PropertyTypeDefaultDescription
minLengthnumber10Minimum segment length in characters. Shorter segments are filtered out.
maxLengthnumber200Maximum segment length. Longer segments are split at clause boundaries.
preserveWhitespacebooleanfalseWhen true, preserves leading/trailing whitespace in segments.

The built-in splitter handles:

  • Abbreviations: Mr., Mrs., Ms., Dr., Prof., St., Jr., Sr., vs., etc., e.g., i.e., Fig., Approx., Dept., Est., Govt., Inc., Corp., Ltd., Co., U.S., U.K., U.N.
  • Decimal numbers: 98.6, 3.14, $9.99 -- periods between digits are not treated as boundaries.
  • Ellipsis: ... -- consecutive periods are not treated as boundaries.
  • URLs and domains: example.com -- periods followed immediately by a letter or digit (no space) are not treated as boundaries.
  • Single-letter initials: A. B. Smith -- single uppercase letters followed by a period are not treated as boundaries.
  • Quoted strings: Sentence boundaries inside quoted strings (double quotes, smart quotes) are ignored.
  • Long sentence fallback: Sentences exceeding maxLength are split at semicolons, em dashes (---, unicode em dash), then commas (only when both halves meet minLength).

createSegment(text: string, index: number): SegmentInfo

Create a new segment in pending state with a unique UUID, timestamp, and the given text and index.

import{createSegment}from'tts-queue';constsegment=createSegment('Hello world.',0);// { id: 'uuid', text: 'Hello world.', index: 0, state: 'pending', addedAt: Date }

transitionSegment(segment: SegmentInfo, newState: SegmentState, extra?: Partial<SegmentInfo>): SegmentInfo

Immutably transition a segment to a new state. Enforces the valid state transition graph and sets appropriate timestamps (synthesisStartedAt, synthesisCompletedAt, playbackStartedAt, playbackCompletedAt). Throws TTSQueueError on invalid transitions. The original segment object is never mutated.

Valid transitions:

pending -> synthesizing | cancelled
synthesizing -> synthesized | failed | cancelled
synthesized -> playing | cancelled
playing -> played | failed | cancelled
played -> (terminal)
failed -> (terminal)
cancelled -> (terminal)

TTSQueueError

Custom error class for all errors originating from the queue. Extends Error with additional context fields.

import{TTSQueueError}from'tts-queue';try{// ...}catch(err){if(errinstanceofTTSQueueError){console.log(err.name);// 'TTSQueueError'console.log(err.stage);// 'synthesis' | 'playback' | 'splitting' | 'internal'console.log(err.cause);// underlying Error, if anyconsole.log(err.segment);// SegmentInfo, if associated with a segment}}

Error Factory Functions

FunctionStageDescription
synthErrorsynthesisTTS provider synthesis failure
playbackErrorplaybackAudio sink playback failure
splittingErrorsplittingText splitting failure
internalErrorinternalQueue internal error (invalid state)

Each factory accepts (message: string, cause?: Error, segment?: SegmentInfo) and returns a TTSQueueError.

import{synthError}from'tts-queue';consterr=synthError('Provider timeout',newError('ETIMEDOUT'),segmentInfo);

Configuration

QueueOptions

PropertyTypeRequiredDefaultDescription
providerTTSProviderYes--TTS synthesis provider
sinkAudioSinkYes--Audio output destination
splittingSplittingOptionsNo{}Sentence splitting configuration
concurrencynumberNo1Maximum concurrent synthesis calls
prefetchCountnumberNo2Number of segments to pre-fetch ahead of playback
onSegmentStart(segment: SegmentInfo) => voidNo--Callback when a segment begins synthesis
onSegmentEnd(segment: SegmentInfo) => voidNo--Callback when a segment finishes playback
onSegmentError(error: TTSQueueError, segment) => voidNo--Callback on segment-level errors
onQueueEmpty() => voidNo--Callback when all segments have completed

SplittingOptions

PropertyTypeDefaultDescription
maxCharsnumber200Maximum segment length before clause-boundary split
minCharsnumber10Minimum segment length; shorter segments are filtered
on'sentence' | 'word' | 'paragraph''sentence'Splitting strategy hint
custom(text: string) => string[]--Custom splitter function; overrides built-in logic

TTSProvider Interface

interfaceTTSProvider{synthesize(text: string,options?: SynthesisOptions): Promise<AudioData>;synthesizeStream?(text: string,options?: SynthesisOptions): AsyncIterable<AudioChunk>;}
MethodRequiredDescription
synthesizeYesSynthesize text into a complete audio buffer
synthesizeStreamNoSynthesize text as a stream of audio chunks

SynthesisOptions

PropertyTypeDescription
voicestringVoice identifier for the TTS engine
speednumberPlayback speed multiplier
formatAudioFormatDesired output audio format
sampleRatenumberDesired output sample rate in Hz

AudioSink Interface

interfaceAudioSink{play(audio: AudioData,segment: SegmentInfo): Promise<void>;pause?(): Promise<void>;resume?(): Promise<void>;stop?(): Promise<void>;}
MethodRequiredDescription
playYesPlay audio data for a given segment
pauseNoPause current playback
resumeNoResume paused playback
stopNoImmediately stop all playback

AudioData

PropertyTypeRequiredDescription
bufferBufferYesRaw audio bytes
formatAudioFormatYesAudio codec: 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'
sizeBytesnumberYesSize of the audio buffer in bytes
sampleRatenumberNoSample rate in Hz
channelsnumberNoNumber of audio channels
durationMsnumberNoDuration of the audio in milliseconds

SegmentInfo

PropertyTypeDescription
idstringUnique UUID for the segment
textstringSource text for the segment
indexnumber0-based position in the queue
stateSegmentStateCurrent lifecycle state
addedAtDateTimestamp when the segment was created
synthesisStartedAtDateTimestamp when synthesis began (optional)
synthesisCompletedAtDateTimestamp when synthesis completed (optional)
playbackStartedAtDateTimestamp when playback began (optional)
playbackCompletedAtDateTimestamp when playback completed (optional)
durationMsnumberAudio duration in milliseconds (optional)
errorErrorError that caused failure (optional)

Error Handling

All errors emitted by the queue are instances of TTSQueueError with a stage property indicating where the error originated.

Synthesis Errors

When a TTS provider's synthesize() call throws or rejects, the segment transitions to failed, a segment:error event is emitted with stage: 'synthesis', and the queue advances to the next segment. The queue does not stop.

queue.on('segment:error',(error,segment)=>{if(error.stage==='synthesis'){console.error(`Synthesis failed for "${segment.text}":`,error.cause);}});

Playback Errors

When the audio sink's play() method throws, the segment transitions to failed, a segment:error event is emitted with stage: 'playback', and the queue advances to the next segment.

Cancellation Errors

If a synthesis call is aborted via cancel(), the AbortController signal fires and the segment transitions to cancelled without emitting an error event. This is the expected path for interruptions.

Closed Queue

Calling push() or pushImmediate() on a closed queue throws a TTSQueueError with stage: 'internal' and the message "Queue is closed".

Invalid State Transitions

Attempting an invalid segment state transition (e.g., pending to played) throws a TTSQueueError with stage: 'internal'. This guards against programming errors in provider or sink implementations.


Advanced Usage

Priority Interruption

Replace the current playback with new content immediately:

// User asks a new question while the previous answer is playingawaitqueue.pushImmediate('Here is the answer to your new question.');// All pending/in-progress segments are cancelled, new text takes priority

Selective Cancellation

Cancel specific segments by ID while allowing others to continue:

constsegments=awaitqueue.push('Sentence one. Sentence two. Sentence three.');// Cancel only the last segmentawaitqueue.cancel([segments[2].id]);

Custom Sentence Splitting

Override the built-in splitter with your own logic:

constqueue=createQueue({
provider,
sink,splitting: {custom: (text)=>text.split(/\n\n/),// Split on double newlines},});

Event-Driven Progress Tracking

queue.on('segment:start',(segment)=>{console.log(`Synthesizing: "${segment.text}" (segment ${segment.index})`);});queue.on('segment:end',(segment)=>{console.log(`Finished: "${segment.text}" (${segment.durationMs}ms)`);});queue.on('state:change',(state)=>{console.log(`Queue state: ${state}`);});queue.on('queue:empty',()=>{console.log('All segments processed');});

Monitoring with Callbacks

constqueue=createQueue({
provider,
sink,onSegmentStart: (seg)=>metrics.trackSynthesisStart(seg.id),onSegmentEnd: (seg)=>metrics.trackSynthesisEnd(seg.id,seg.durationMs),onSegmentError: (err,seg)=>logger.error({ err,segmentId: seg.id}),onQueueEmpty: ()=>logger.info('Queue drained'),});

OpenAI TTS Provider Example

importOpenAIfrom'openai';importtype{TTSProvider,AudioData}from'tts-queue';constopenai=newOpenAI();constopenaiProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitopenai.audio.speech.create({model: 'tts-1',voice: 'alloy',input: text,response_format: 'mp3',});constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

ElevenLabs TTS Provider Example

importtype{TTSProvider,AudioData}from'tts-queue';constelevenLabsProvider: TTSProvider={asyncsynthesize(text: string): Promise<AudioData>{constresponse=awaitfetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,{method: 'POST',headers: {'xi-api-key': process.env.ELEVENLABS_API_KEY!,'Content-Type': 'application/json',},body: JSON.stringify({ text,model_id: 'eleven_monolingual_v1'}),},);constarrayBuffer=awaitresponse.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);return{
buffer,format: 'mp3',sizeBytes: buffer.length,};},};

Test Buffer Sink

Collect all audio data in memory for assertions:

importtype{AudioSink,AudioData,SegmentInfo}from'tts-queue';functioncreateTestSink(): AudioSink&{played: SegmentInfo[]}{constplayed: SegmentInfo[]=[];return{
played,asyncplay(audio: AudioData,segment: SegmentInfo): Promise<void>{played.push(segment);},};}

TypeScript

tts-queue is written in TypeScript and ships with full type declarations (dist/index.d.ts). All exports are fully typed.

Exported Types

importtype{// Core interfacesTTSQueue,TTSProvider,AudioSink,QueueOptions,// Data typesAudioData,AudioChunk,AudioFormat,// 'mp3' | 'wav' | 'ogg' | 'pcm' | 'aac' | 'opus'SynthesisOptions,// Segment typesSegmentInfo,SegmentState,// 'pending' | 'synthesizing' | 'synthesized' | 'playing'// | 'played' | 'cancelled' | 'failed'// Queue typesQueueState,// 'idle' | 'playing' | 'paused' | 'draining' | 'closed'QueueStats,CancelResult,TTSQueueEvents,// SplittingSplittingOptions,SplitOptions,// ErrorsTTSQueueStage,// 'synthesis' | 'playback' | 'splitting' | 'internal'}from'tts-queue';

Exported Values

import{createQueue,splitSentences,createSegment,transitionSegment,TTSQueueError,synthError,playbackError,splittingError,internalError,}from'tts-queue';

License

MIT

About

TTS audio streaming manager with sentence-boundary queuing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages