pipedog is an unofficial community port of Pipecat, not affiliated with Daily.
pipedog

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:

  • PipecatToneReader goes 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's perception: the user's two likeliest emotions with their confidences, and sarcasm, the chance the turn is sarcastic.
  • PipecatToneTagger goes 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 with ExpressionGuidance.BREATHE an 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.

Installation

bun add @pipedog/core @pipedog/tone onnxruntime-node

The 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:

bot.ts
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

ProcessorOptionDefaultDescription
PipecatToneReaderrevision"reader-rc1"The model's release on Hugging Face.
modelDirnullA 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".
threads4CPU threads for one read.
PipecatToneTaggersize"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.
modelDirnullA folder to load the model from, laid out as a release's tagger/17m.
threads1CPU threads for one sentence.
vocabularynullNames 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 processorUnusablePolicy to 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

RuntimeWorksWhat it needs
Bun✅ yesNothing.
Node.js✅ yesNothing.
Deno— not verifiedNothing.
Cloudflare Workers✅ yesNothing.

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).

On this page