Pipecat Tone
Read how the user meant each turn, and voice the bot's plain replies with expression tags, using Pipecat Tone's small local models.
Pipecat Tone is a pair of small models for expressive conversation (alpha): a reader for how
the user spoke and a tagger for how the bot speaks. Both run on the CPU, through ONNX Runtime,
in milliseconds. pipedog ports them as two processors in @pipedog/core, with the models in
@pipedog/tone:
PipecatToneReadergoes between the STT service and the user aggregator. It reads each final transcription, together with the bot's reply before it, and sets the reading as the transcription'sperception: the user's two likeliestemotions with their confidences, andsarcasm, the chance the turn is sarcastic.PipecatToneTaggergoes between the LLM and the TTS service. The LLM writes plain words, and the tagger labels each sentence as it streams in: a delivery, slow spans for details, stressed words, a sound before or after, and withExpressionGuidance.BREATHEan occasional breath. It writes the labels as the expression tags the TTS service performs.
Alpha
Expressive conversation is alpha upstream, and these types are likely to change between releases.
Source
packages/core/src/processors/tone and packages/tone/src.
Models
pipecat-ai/tone-models on Hugging Face.
Installation
bun add @pipedog/core @pipedog/tone onnxruntime-nodeThe processors are in @pipedog/core, which loads the models from @pipedog/tone when it is
installed. @pipedog/tone runs them on @pipedog/onnx, with
onnxruntime-node or onnxruntime-web as an optional peer.
import { PipecatToneReader } from "@pipedog/core/processors/tone/reader/index";
import { PipecatToneTagger } from "@pipedog/core/processors/tone/tagger/index";Usage
Turn on expressive conversation in the PipelineParams, and place the reader after the STT
service and the tagger after the LLM. The ExpressionPolicy's presets steer the tagger, and your
own guidance lines tell the LLM what to do with a reading:
import { SileroVADAnalyzer } from "@pipedog/audio-silero";
import { formatPerception } from "@pipedog/core/expressive/perception";
import { ExpressionGuidance, ExpressionPolicy } from "@pipedog/core/expressive/policy";
import { Pipeline } from "@pipedog/core/pipeline/pipeline";
import { PipelineParams, PipelineWorker } from "@pipedog/core/pipeline/worker";
import { LLMContext } from "@pipedog/core/processors/aggregators/llm_context";
import {
LLMContextAggregatorPair,
LLMUserAggregatorParams,
type UserTurnStoppedMessage,
} from "@pipedog/core/processors/aggregators/llm_response_universal";
import { PipecatToneReader } from "@pipedog/core/processors/tone/reader/index";
import { PipecatToneTagger } from "@pipedog/core/processors/tone/tagger/index";
import type { BaseTransport } from "@pipedog/core/transports/base_transport";
import type { BaseUserTurnStopStrategy } from "@pipedog/core/turns/user_stop/index";
import { DeepgramSTTService } from "@pipedog/deepgram/stt";
import { ElevenLabsDialogueTTSService } from "@pipedog/elevenlabs/dialogue/tts";
import { OpenAILLMService } from "@pipedog/openai/llm";
declare const transport: BaseTransport;
const stt = new DeepgramSTTService({ apiKey: process.env.DEEPGRAM_API_KEY ?? "" });
// Reads how each user turn was meant. Its model downloads from Hugging Face at start.
const toneReader = new PipecatToneReader();
// Voices the LLM's plain replies, a sentence at a time.
const toneTagger = new PipecatToneTagger();
const llm = new OpenAILLMService({
apiKey: process.env.OPENAI_API_KEY ?? "",
settings: new OpenAILLMService.Settings({
systemInstruction: "You are a warm assistant in a voice conversation. Keep replies brief.",
}),
});
// Eleven v3 and v4 perform audio tags.
const tts = new ElevenLabsDialogueTTSService({
apiKey: process.env.ELEVENLABS_API_KEY ?? "",
settings: new ElevenLabsDialogueTTSService.Settings({
voice: process.env.ELEVENLABS_VOICE_ID ?? "",
}),
});
const context = new LLMContext();
const aggregators = new LLMContextAggregatorPair(context, {
userParams: new LLMUserAggregatorParams({ vadAnalyzer: new SileroVADAnalyzer() }),
});
const userAggregator = aggregators.user();
const pipeline = new Pipeline([
transport.input(),
stt,
toneReader, // how each user turn was meant
userAggregator,
llm,
toneTagger, // how each sentence is voiced
tts,
transport.output(),
aggregators.assistant(),
]);
const worker = new PipelineWorker(pipeline, {
params: new PipelineParams({
enableExpressiveConversation: true,
expressionPolicy: new ExpressionPolicy({
guidance: [
ExpressionGuidance.MATCH_STYLE,
ExpressionGuidance.SLOW_FOR_DETAILS,
ExpressionGuidance.BREATHE,
"If the user sounds angry at 0.7 or above, apologize once, then focus on helping.",
],
}),
}),
});
userAggregator.eventHandler(
"on_user_turn_stopped",
async (_aggregator, _strategy: BaseUserTurnStopStrategy, message: UserTurnStoppedMessage) => {
if (message.perception) console.log("tone:", formatPerception(message.perception));
},
);With a tagger in the pipeline, the LLM is taught to read the user's tone but no tags, and the
context keeps its replies plain. The user aggregator writes each turn's reading at the head of the
user's message, such as
([emotion frustrated=0.86, neutral=0.10],[sarcasm value=0.03]) Where is my order?, and on_user_turn_stopped gets it in UserTurnStoppedMessage.perception.
Each processor works without the other: with the reader alone, the LLM writes the tags itself (when the TTS service performs them), and the tagger alone voices replies without knowing how the user sounded.
Options
| Processor | Option | Default | Description |
|---|---|---|---|
PipecatToneReader | revision | "reader-rc1" | The model's release on Hugging Face. |
modelDir | null | A folder (or an http(s): or file: URL) to load the model from instead of downloading it. | |
precision | "fp16" | "fp16" gives the same readings at half the size; or "fp32". | |
threads | 4 | CPU threads for one read. | |
PipecatToneTagger | size | "17m" | "17m", or "32m", which agrees with its teacher a little more often and takes nearly three times as long a sentence. |
revision | "tagger-rc1" | The model's release on Hugging Face. | |
modelDir | null | A folder to load the model from, laid out as a release's tagger/17m. | |
threads | 1 | CPU threads for one sentence. | |
vocabulary | null | Names to write first for a delivery, sound or kind of breath, such as { [ExpressionDelivery.WARM]: ["cheerful"] }, where the TTS service performs them. |
Both also take openModel, a function that opens the model your own way.
// A larger tagger, and a reader loaded from a folder of your own instead of Hugging Face.
const largerTagger = new PipecatToneTagger({ size: "32m", threads: 2 });
const localReader = new PipecatToneReader({ modelDir: "/models/tone/reader", precision: "fp32" });Notes
- The model loads while the pipeline is set up, once a process, and every reader (or tagger)
shares it. The first load downloads it from Hugging Face into
~/.cache/pipedog/tone(or$PIPEDOG_CACHE_DIR/tone), which holds the pipeline's start, so download it ahead, such as when building the image. - A model that can't be loaded is reported as an error that leaves the processor unusable:
transcriptions pass through unread, and replies pass through as the LLM wrote them, with the
LLM writing the tags itself. Set the worker's
processorUnusablePolicyto end the pipeline instead. - The tagger was trained with the Cartesia, ElevenLabs, Fish Audio and MiniMax TTS services, and reads any other as a generic one. It only tags with expressive conversation on.
- Each read or sentence waits for the model: a few milliseconds a turn for the reader, about 15 milliseconds a sentence for the 17m tagger.
Runtimes
| Runtime | Works | What it needs |
|---|---|---|
| Bun | ✅ yes | Nothing. |
| Node.js | ✅ yes | Nothing. |
| Deno | — not verified | Nothing. |
| Cloudflare Workers | ✅ yes | Nothing. |
Generated from pipecat.runtimes and pipecat.compat in packages/tone/package.json: ✅ means the compat smoke test passes on that runtime.
The download cache needs a file system. On Cloudflare Workers, host the model files and pass their
https: URL as modelDir.
Upstream runs the models off the event loop in a thread. Here the ONNX runtime runs them
asynchronously, and the model's tag() returns a promise.
pipedog is an unofficial community TypeScript port of Pipecat. It is not affiliated with or endorsed by Daily or the Pipecat project.
Portions adapted from the Pipecat documentation, Copyright (c) 2024–2025 Daily, BSD-2-Clause (see apps/docs/LICENSE-pipecat-docs).
TTS audio cache
Keep the audio of fixed lines (a greeting, a disclaimer) and play it without asking the TTS provider again, with TTSAudioCache and warmAudioCache().
Typed tools
Define LLM function calls with a Zod, Valibot or ArkType schema (Standard Schema) and get the JSON schema, inferred handler types and argument validation.