@tanstack/ai
Version:
Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.
492 lines (455 loc) • 15 kB
text/typescript
/**
* TTS Activity
*
* Generates speech audio from text using text-to-speech models.
* This is a self-contained module with implementation, types, and JSDoc.
*/
import { aiEventClient } from '@tanstack/ai-event-client'
import { streamGenerationResult } from '../stream-generation-result.js'
import { resolveDebugOption } from '../../logger/resolve'
import {
applyGenerationResultTransforms,
createGenerationContext,
runGenerationAbort,
runGenerationError,
runGenerationFinish,
runGenerationStart,
runGenerationUsage,
} from '../middleware/run'
import {
abortReasonMessage,
createActivityAbortControls,
isActivityAbortError,
raceWithAbort,
} from '../../utilities/activity-abort'
import type { InternalLogger } from '../../logger/internal-logger'
import type { DebugOption } from '../../logger/types'
import type { GenerationMiddleware } from '../middleware/types'
import type { TTSAdapter, TTSCapabilities } from './adapter'
import type {
ListVoicesOptions,
ListVoicesResult,
StreamChunk,
TTSResult,
TTSTurn,
} from '../../types'
// ===========================
// Activity Kind
// ===========================
/** The adapter kind this activity handles */
export const kind = 'tts' as const
// ===========================
// Type Extraction Helpers
// ===========================
/**
* Extract provider options from a TTSAdapter via ~types.
*/
export type TTSProviderOptions<TAdapter> = TAdapter extends {
'~types': { providerOptions: infer P extends object }
}
? P
: object
// ===========================
// Activity Options Type
// ===========================
/**
* Options for the TTS activity.
* The model is extracted from the adapter's model property.
*
* @template TAdapter - The TTS adapter type
* @template TStream - Whether to stream the output
*/
export type TTSActivityOptions<
TAdapter extends TTSAdapter<string, TTSProviderOptions<TAdapter>>,
TStream extends boolean = false,
> = TTSActivityOptionsBase<TAdapter, TStream> &
(
| {
/** The text to convert to speech */
text: string
turns?: undefined
}
| {
text?: undefined
/**
* Multi-voice dialogue turns, one per line of the script. Mutually
* exclusive with `text`.
*
* Only adapters that declare `capabilities.maxSpeakers` accept these
* (ElevenLabs 10 voices, Gemini 2); anything else throws before the
* request leaves the process.
*/
turns: Array<TTSTurn>
}
)
/** Shared half of {@link TTSActivityOptions} — everything except text/turns. */
interface TTSActivityOptionsBase<
TAdapter extends TTSAdapter<string, TTSProviderOptions<TAdapter>>,
TStream extends boolean = false,
> {
/** The TTS adapter to use (must be created with a model) */
adapter: TAdapter & { kind: typeof kind }
/** The voice to use for generation */
voice?: string
/**
* Ask for `alignment` (character/word timings) and `segments` (per-turn
* spans) on the result. Only adapters that declare
* `capabilities.timestamps` accept it — on ElevenLabs it is a different
* endpoint, on BytePlus a different request flag, so it cannot be inferred.
*/
timestamps?: boolean
/** The output audio format */
format?: 'mp3' | 'opus' | 'aac' | 'flac' | 'wav' | 'pcm'
/** The speed of the generated audio (0.25 to 4.0) */
speed?: number
/** Provider-specific options for TTS generation */
modelOptions?: TTSProviderOptions<TAdapter>
/**
* Whether to stream the generation result.
* When true, returns an AsyncIterable<StreamChunk> for streaming transport.
* When false or not provided, returns a Promise<TTSResult>.
*
* @default false
*/
stream?: TStream
/**
* Enable debug logging. Pass `true` to enable all categories, `false` to
* silence everything including errors, or a `DebugConfig` object for granular
* control and/or a custom `Logger`.
*/
debug?: DebugOption
/**
* Observe-only middleware notified on start, usage, success, and error. Pass
* `otelMiddleware()` to emit OpenTelemetry spans, or implement the
* `GenerationMiddleware` contract for a custom backend.
*/
middleware?: Array<GenerationMiddleware>
/** Stable conversation/thread id for correlating this run when persisted. */
threadId?: string
/** Stable run id for correlating this run when persisted. */
runId?: string
/**
* Maximum duration of this activity invocation in milliseconds.
* No SDK-wide default — choose a value suitable for the provider and job.
* Composed with {@link abortSignal}; the first abort wins.
*/
timeout?: number
/**
* Caller cancellation signal (request disconnects, job/runtime cancellation).
* Composed with {@link timeout} into an effective signal forwarded to the
* adapter. Request-specific — not stored on global provider client config.
*/
abortSignal?: AbortSignal
}
// ===========================
// Activity Result Type
// ===========================
/**
* Result type for the TTS activity.
* - If stream is true: AsyncIterable<StreamChunk>
* - Otherwise: Promise<TTSResult>
*/
export type TTSActivityResult<TStream extends boolean = false> =
TStream extends true ? AsyncIterable<StreamChunk> : Promise<TTSResult>
function createId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
/**
* Validate the text/turns/timestamps trio against what the adapter declares,
* and return the `text` every adapter receives.
*
* For a dialogue request that text is the turn scripts joined by newlines:
* dialogue-aware adapters read `turns` and ignore it, but it keeps `text`
* non-optional on the adapter contract and gives the devtools event and the
* artifact inputs something truthful to show.
*/
function resolveSpeechText(
adapter: { name: string; capabilities?: TTSCapabilities },
input: { text?: string; turns?: Array<TTSTurn>; timestamps?: boolean },
): string {
const { text, turns, timestamps } = input
if (timestamps && !adapter.capabilities?.timestamps) {
throw new Error(
`${adapter.name} cannot return timestamps. Drop \`timestamps: true\` — the result would have no alignment to read.`,
)
}
if (turns) {
if (text !== undefined) {
throw new Error(
'generateSpeech() takes either `text` or `turns`, not both.',
)
}
if (turns.length === 0) {
throw new Error('generateSpeech() `turns` must not be empty.')
}
const maxSpeakers = adapter.capabilities?.maxSpeakers
if (maxSpeakers === undefined) {
throw new Error(
`${adapter.name} cannot generate dialogue. Pass \`text\` (and \`voice\`) instead of \`turns\`.`,
)
}
const speakers = new Set(turns.map((turn) => turn.voice)).size
if (speakers > maxSpeakers) {
throw new Error(
`${adapter.name} accepts at most ${maxSpeakers} distinct voice${maxSpeakers === 1 ? '' : 's'} per request; received ${speakers}.`,
)
}
return turns.map((turn) => turn.text).join('\n')
}
if (text === undefined) {
throw new Error('generateSpeech() requires either `text` or `turns`.')
}
return text
}
// ===========================
// Activity Implementation
// ===========================
/**
* TTS activity - generates speech from text.
*
* Uses AI text-to-speech models to create audio from natural language text.
*
* @example Generate speech from text
* ```ts
* import { generateSpeech } from '@tanstack/ai'
* import { openaiSpeech } from '@tanstack/ai-openai'
*
* const result = await generateSpeech({
* adapter: openaiSpeech('tts-1-hd'),
* text: 'Hello, welcome to TanStack AI!',
* voice: 'nova'
* })
*
* console.log(result.audio) // base64-encoded audio
* ```
*
* @example With format and speed options
* ```ts
* const result = await generateSpeech({
* adapter: openaiSpeech('tts-1'),
* text: 'This is slower speech.',
* voice: 'alloy',
* format: 'wav',
* speed: 0.8
* })
* ```
*/
export function generateSpeech<
TAdapter extends TTSAdapter<string, TTSProviderOptions<TAdapter>>,
TStream extends boolean = false,
>(options: TTSActivityOptions<TAdapter, TStream>): TTSActivityResult<TStream> {
if (options.stream) {
return streamGenerationResult(
// Only `runId` is taken from the resolved wire identity. `threadId` stays
// the CALLER's: `streamGenerationResult` mints one for the RUN_* chunks
// when none was passed, and spreading that over the options would hand
// middleware a thread id known to nobody, which persistence would then
// file the run under. Matches `generateVideo`.
(resolved) => runGenerateSpeech({ ...options, runId: resolved.runId }),
options,
) as TTSActivityResult<TStream>
}
return runGenerateSpeech(options) as TTSActivityResult<TStream>
}
/**
* Run the core TTS generation logic (non-streaming).
*/
async function runGenerateSpeech<
TAdapter extends TTSAdapter<string, TTSProviderOptions<TAdapter>>,
>(options: TTSActivityOptions<TAdapter, boolean>): Promise<TTSResult> {
const {
adapter,
stream: _stream,
debug: _debug,
middleware,
threadId,
runId,
timeout,
abortSignal: callerAbortSignal,
...rest
} = options
const model = adapter.model
const text = resolveSpeechText(adapter, rest)
const requestId = createId('speech')
const startTime = Date.now()
const logger: InternalLogger = resolveDebugOption(options.debug)
const abortControls = createActivityAbortControls({
timeout,
abortSignal: callerAbortSignal,
})
const providerName =
(adapter as { name?: string; provider?: string }).provider ??
(adapter as { name?: string }).name ??
'unknown'
const mwCtx = createGenerationContext({
requestId,
activity: 'tts',
provider: adapter.name,
model,
modelOptions: rest.modelOptions,
artifactInputs: {
text,
voice: rest.voice,
format: rest.format,
speed: rest.speed,
},
threadId,
runId,
createId,
})
await runGenerationStart(middleware, mwCtx)
aiEventClient.emit('speech:request:started', {
requestId,
provider: adapter.name,
model,
text,
voice: rest.voice,
format: rest.format,
speed: rest.speed,
modelOptions: rest.modelOptions as Record<string, unknown> | undefined,
timestamp: startTime,
})
logger.request(`activity=generateSpeech provider=${providerName}`, {
provider: providerName,
model,
})
try {
const rawResult = await raceWithAbort(
adapter.generateSpeech({
...rest,
text,
model,
logger,
...(abortControls.signal ? { abortSignal: abortControls.signal } : {}),
}),
abortControls.signal,
)
abortControls.clear()
const result = await applyGenerationResultTransforms(mwCtx, rawResult)
const duration = Date.now() - startTime
aiEventClient.emit('speech:request:completed', {
requestId,
provider: adapter.name,
model,
audio: result.audio,
format: result.format,
audioDuration: result.duration,
contentType: result.contentType,
duration,
modelOptions: rest.modelOptions as Record<string, unknown> | undefined,
timestamp: Date.now(),
})
if (result.usage) {
aiEventClient.emit('speech:usage', {
requestId,
model,
usage: result.usage,
modelOptions: rest.modelOptions as Record<string, unknown> | undefined,
timestamp: Date.now(),
})
}
logger.output(`activity=generateSpeech bytes=${result.audio.length}`, {
bytes: result.audio.length,
contentType: result.contentType,
})
if (result.usage) await runGenerationUsage(middleware, mwCtx, result.usage)
await runGenerationFinish(middleware, mwCtx, {
duration,
usage: result.usage,
})
return result
} catch (error) {
abortControls.clear()
const duration = Date.now() - startTime
const err = error as Error
aiEventClient.emit('speech:request:error', {
requestId,
provider: adapter.name,
model,
error: { message: err.message, name: err.name },
duration,
modelOptions: rest.modelOptions as Record<string, unknown> | undefined,
timestamp: Date.now(),
})
if (isActivityAbortError(error, abortControls.signal)) {
await runGenerationAbort(middleware, mwCtx, {
reason: abortReasonMessage(error, abortControls.signal),
duration,
})
} else {
await runGenerationError(middleware, mwCtx, {
error,
duration,
})
}
logger.errors('generateSpeech activity failed', {
error,
source: 'generateSpeech',
})
throw error
}
}
// ===========================
// Voice Catalog
// ===========================
/**
* Options for {@link listVoices}.
*/
export interface ListVoicesActivityOptions<
TAdapter extends TTSAdapter<string, TTSProviderOptions<TAdapter>>,
> extends ListVoicesOptions {
/** The speech adapter whose catalog to read */
adapter: TAdapter & { kind: typeof kind }
}
/**
* List the voices an account can pass to `generateSpeech()`.
*
* Only providers with a per-account catalog implement this. A provider whose
* voices are a fixed list publishes that list as a const in its package, so
* import it from there rather than calling this.
*
* @example Find the voices you created
* ```ts
* import { listVoices } from '@tanstack/ai'
* import { elevenlabsSpeech } from '@tanstack/ai-elevenlabs'
*
* const { voices } = await listVoices({
* adapter: elevenlabsSpeech('eleven_v3'),
* origins: ['generated', 'cloned'],
* })
* ```
*/
export async function listVoices<
TAdapter extends TTSAdapter<string, TTSProviderOptions<TAdapter>>,
>(options: ListVoicesActivityOptions<TAdapter>): Promise<ListVoicesResult> {
const { adapter, ...rest } = options
const list = adapter.listVoices
if (!list) {
throw new Error(
`The ${adapter.name} speech adapter has no per-account voice catalog to list. Its voices are a fixed set — import the voice list or union its package exports instead (for example \`GeminiTTSVoices\` from @tanstack/ai-gemini, or the \`OpenAITTSVoice\` union from @tanstack/ai-openai).`,
)
}
return await list.call(adapter, rest)
}
// ===========================
// Options Factory
// ===========================
/**
* Create typed options for the generateSpeech() function without executing.
*/
export function createSpeechOptions<
TAdapter extends TTSAdapter<string, TTSProviderOptions<TAdapter>>,
TStream extends boolean = false,
>(
options: TTSActivityOptions<TAdapter, TStream>,
): TTSActivityOptions<TAdapter, TStream> {
return options
}
// Re-export adapter types
export type {
TTSAdapter,
TTSAdapterConfig,
TTSCapabilities,
AnyTTSAdapter,
} from './adapter'
export { BaseTTSAdapter } from './adapter'