Skip to content
5 min read · 986 words

TTS batteries

ADK includes two Text-to-Speech (TTS) engines for synthesizing audio from text: an OS-native CLI engine and a model-backed transformers.js engine. The shared contract takes text and returns a GeneratedMediaOutput descriptor — the same generic shape the generation domain uses ({ kind: MediaKind; mimeType: string; bytes: Uint8Array; filename?: string }); both TTS adapters resolve it specifically to kind: 'audio', mimeType: 'audio/wav', filename: 'speech.wav'. Unlike the generation and embeddings domains, the shared option base is explicitly model-less: the native engine has no model concept, so any model-backed engine extends the base to add its own required model.

Barrels, side by side

Transformers.jsNative
Subpath@nhtio/adk/batteries/tts/transformers_js (or @nhtio/adk/batteries/tts)@nhtio/adk/batteries/tts/native
AdapterTransformersJsTtsAdapterNativeTtsAdapter
Options typeTransformersJsTtsAdapterOptionsNativeTtsAdapterOptions
ValidatorvalidateTransformersJsTtsOptionsvalidateNativeTtsOptions
Transporton-device, @huggingface/transformers peerOS shell (say, espeak-ng, PowerShell)
EnvironmentEnvironment-neutral (Browser / Node)Node-only

Note: The aggregate barrel @nhtio/adk/batteries/tts re-exports the transformers.js engine but omits the native engine entirely. Because the native engine relies on node:child_process and node:fs, exporting it from the top-level would break browser bundlers. Import it from its deep subpath.

Shared options (BaseTtsAdapterOptions)

Both engines accept and normalize these shared options, either in the constructor or as per-call overrides:

  • voice?: string: The voice identifier. Mapped to say -v <voice>, espeak-ng -v <voice>, PowerShell SelectVoice, or a transformers.js speaker_embeddings URL/string.
  • rate?: number: A multiplier where 1 is engine-normal. Mapped per-engine: say/espeak-ng compute round(175 * rate) words-per-minute (clamped 80–500), PowerShell maps it to a -10..10 band, and transformers.js applies it as speed. The transformers.js adapter validates rate as a positive number (zero or negative throws E_INVALID_TRANSFORMERS_JS_TTS_OPTIONS); the native adapter accepts any number.

Transformers.js (TransformersJsTtsAdapter)

The transformers.js engine requires a model and works with any transformers.js text-to-speech model — MMS-VITS ('Xenova/mms-tts-eng') and SpeechT5 ('Xenova/speecht5_tts', which additionally needs speakerEmbeddings) are the verified examples. It shares the same experimental-ish weight as the generation and specialist tjs engines: it downloads hundreds of megabytes of weights and can take significant CPU time to synthesize audio. Run it behind forkIsolated for anything beyond a throwaway script. Pin @huggingface/transformers 4.2.0 as the verified minimum.

Options

OptionTypeDefaultDescription
modelstringRequiredThe TTS model id (e.g. Xenova/mms-tts-eng).
speakerEmbeddingsFloat32Array|string|URLundefinedRequired by SpeechT5-family models. Ignored by MMS-VITS.
numInferenceStepsnumberundefinedDenoising step count for models that support it.
pipelineTransformersJsTtsPipelineundefinedPre-built pipeline to bypass lazy creation.
createPipelineFunctionpipeline('text-to-speech')Override the pipeline factory.
devicestringEnvironment defaultInference device ('auto', 'webgpu', 'wasm', 'cpu', etc).
dtypestringEnvironment defaultQuantization/precision dtype.
modelSourceFunctionHF defaultCustom resolver for serving weights (OPFS, bundled, etc).
onInitProgressFunctionundefinedCallback for model download/compile progress.

(Also accepts the shared voice and rate, plus the BatteryLifecycleHooks common to all on-device ADK batteries).

Per-call overrides (TransformersJsSynthesizeOptions) accept voice, rate, speakerEmbeddings, and numInferenceSteps.

Native (NativeTtsAdapter)

The native engine is zero-config valid (new NativeTtsAdapter()). It auto-detects the platform and shells out to macOS say, Linux espeak-ng, or Windows PowerShell System.Speech. It uses lazy node:* imports so tests remain hermetic. PowerShell input is secured via psSingleQuotedLiteral to prevent script injection.

Options

OptionTypeDefaultDescription
platform'darwin'|'linux'|'win32'process.platformOverride auto-detected platform.
commandstringPlatform defaultExecutable-only override (e.g. /usr/bin/say).
wordsPerMinutenumberComputed from rateDirect WPM override for darwin/linux.
pitchnumberundefinedPitch override (espeak-ng only).
extraArgsstring[][]Extra CLI flags for darwin/linux. Ignored on win32.
timeoutMsnumber60000Process timeout in milliseconds.
executorTtsBinaryExecutorchild_process wrapperInjectable CLI executor seam.
fs / tmpdir / randomNameTtsFsLike / Function / Functionnode:fs/promises / node:os / cryptoInjectable filesystem seams.
isAvailable() => booleanNativeTtsAdapter.isAvailableInjectable availability probe (overrides the default platform check).

(Also accepts the shared voice and rate).

Per-call overrides (NativeSynthesizeOptions) accept voice, rate, and pitch.

Exceptions

CodeEngineStatusFatal?Description
E_INVALID_TRANSFORMERS_JS_TTS_OPTIONSTransformers.js529YesAdapter options fail schema validation.
E_TRANSFORMERS_JS_TTS_ENGINE_ERRORTransformers.js502NoModel load fails or pipeline throws.
E_INVALID_NATIVE_TTS_OPTIONSNative529YesAdapter options fail schema validation.
E_NATIVE_TTS_UNSUPPORTED_PLATFORMNative529YesPlatform is not darwin, linux, or win32.
E_NATIVE_TTS_ENGINE_ERRORNative502NoBinary fails, empty output, or invalid RIFF/WAVE magic.
E_NATIVE_TTS_TIMEOUTNative504NoBinary aborted for exceeding timeoutMs.

Wiring into an agent

Since TTS generates media from text, it is typically exposed to the LLM via a BYO Tool. The pattern is identical to the image-generation recipesynthesize() returns a GeneratedMediaOutput, which the handler persists with ctx.storeMediaBytes and wraps in Media.toolGenerated:

typescript
import { Tool, Media } from '@nhtio/adk'
import { validator } from '@nhtio/validation'
import { NativeTtsAdapter } from '@nhtio/adk/batteries/tts/native'
import type { DispatchContext } from '@nhtio/adk/types'

const tts = new NativeTtsAdapter()

const speak = (ctx: DispatchContext) =>
  new Tool({
    name: 'synthesize_speech',
    description: 'Speaks text aloud, returning a WAV audio clip.',
    inputSchema: validator.object({
      text: validator.string().description('What to say').required(),
    }),
    async handler({ text }) {
      let output
      try {
        output = await tts.synthesize(text) // a single GeneratedMediaOutput, not an array
      } catch (err) {
        return `Error (SYNTHESIS_FAILED): ${err instanceof Error ? err.message : String(err)}`
      }
      const reader = await ctx.storeMediaBytes(output.filename ?? 'speech.wav', output.bytes)
      return Media.toolGenerated({
        kind: output.kind,
        mimeType: output.mimeType,
        filename: output.filename ?? 'speech.wav',
        reader,
      })
    },
  })

(For browser builds, swap NativeTtsAdapter for the environment-neutral TransformersJsTtsAdapter from @nhtio/adk/batteries/tts/transformers_js — the handler body is unchanged since both return GeneratedMediaOutput.)

Where to go next