Pipecat
Read this if you run a voice bot on Pipecat and want the speech it synthesises to carry a watermark and an evidence entry. You will add one frame processor after your TTS service. Skip if you mark stored recordings or files (Audio), or your bot runs at 8 kHz (streaming marks nothing below 16 kHz; "8 kHz bots" below). A keyed deployment does reach callers over the phone ("Survival" below). Time: 15 minutes plus the AudioSeal download. Needs: Python 3.12,
pip install laidline-pipecat, alaidline.yamlfromlaidline init, TTS at 16 kHz or more.
In short
LaidlineTTSProcessorsits right after the TTS service and marks everyTTSAudioRawFrame.- It writes one evidence entry per utterance; the streaming entries carry no Content Credentials manifest.
- It needs an in-process
Marker: keys, models and evidence stay on the host that runs the bot. - Below 16 kHz the streaming mark does not survive, so such frames pass through unmarked.
- Unkeyed, the streaming mark does not survive an 8 kHz phone leg. Keyed (
watermark.payload_keys; whatlaidline initsets up), the stream marker's payload reads through G.711, G.726, MP3 and Opus from 12 kbit/s when the audio stays on its 20 ms grid and runs 8 s or more (Audio).
laidline-pipecat is a Pipecat FrameProcessor placed right after the TTS service. It marks every
TTSAudioRawFrame with the streaming AudioSeal mark and writes one evidence entry per utterance. The package page on
PyPI (laidline-pipecat) repeats the installation, the pipeline snippet
and the frame rules.
# laidline-pipecat brings laidline[audio] and pipecat-ai>=1.12,<2; the websocket transport needs uvicorn as well # (CUDA torch makes a 5.7 GB environment; the AudioSeal mark runs on the CPU, see Install for the CPU build) pip install laidline-pipecat "pipecat-ai[openai,websocket]>=1.12,<2" uvicorn
from laidline import Marker, MarkContext from laidline_pipecat import LaidlineTTSProcessor marker = Marker.from_config("laidline.yaml") pipeline = Pipeline([transport.input(), stt, llm, tts, LaidlineTTSProcessor(marker, ctx=MarkContext(request_id="call-1")), transport.output()])
The processor needs an in-process Marker (keys, models and evidence stay on the host running the bot); a
MarkClient is refused, because the streaming path has no HTTP form. It uses the streaming session of audio.md, so
the entries have watermark.streaming: true, no C2PA manifest (manifest_skipped:streaming) and the TTS
context_id under extra.context_id. The embedder is the streaming card (watermark.audio.stream_kind: block, the
only kind: an experimental context kind was measured and removed: 22.4 % identity). To detect the
streamed mark elsewhere, install laidline[audio] there too; without it detect answers audio_unavailable with the
install hint.
What you need
- The weights before the first run:
laidline models fetch --audio --no-video --no-image(AudioSeal, 187 MB). - A route in your context.
LaidlineTTSProcessor(marker, ctx=...)copiesctxinto every entry, and actxyou pass replaces the default routepipecat:tts: setMarkContext(route=..., request_id=...), or the entries carry no route. - TTS at 16 kHz or more ("8 kHz bots" below). OpenAI TTS gives 24 kHz. In Pipecat 1.x pass the model in the
settings,
OpenAITTSService.Settings(model="gpt-4o-mini-tts", voice="alloy"); themodel=argument is deprecated. - For OpenAI TTS:
OPENAI_API_KEYin the bot's environment, and patience: the tested run took 87 s end to end, most of it OpenAI, whose TTS took 35 to 45 s server-side on most requests that day.
Known limits
- When the TTS is that slow, Pipecat closes the audio context after its 3 s stop-frame timeout with a non-fatal
ErrorFrame("completed with no audio") and reopens it when the audio arrives. The processor still writes one entry per utterance. ProtobufFrameSerializercannot serializeEndFrame(a warning); the client ends on the socket close that follows.- Once, the server kept generating for about 2 minutes after the client disconnected; the cause is not known.
8 kHz bots get no watermark from streaming. Below 16 kHz the streaming mark does not survive (detection 0.0
at 8 kHz), so frames at such rates pass through unchanged and the entry says watermark: null with the warning
watermark_skipped:rate_below_16k. Run the TTS at 16 kHz or more and resample for the phone leg afterwards, or
mark stored recordings in batch mode.
Survival. The streaming mark survives an offline Opus transcode at 24 and 48 kbps (100 percent on 500 LibriSpeech clips).
Unkeyed, it does not survive a simulated phone leg (G.711 codec simulation): 0 percent after G.711 mu-law and
after an 8 kHz round trip. Keyed (watermark.payload_keys), the processor marks through the keyed stream marker
(Marker.audio_stream), and the 70-bit payload reads back through the simulated phone path. With the shipped default
(payload.phase_search off) a 10 s utterance reads 100 of 100 at 16 kHz and 100 of 100 at 24 kHz, and 10 s through
simulated G.711 and through Opus at 24 kbit/s read 100 of 100 each (100 per cell; ffmpeg codecs, not carrier paths).
Three conditions apply. The payload needs 8 s of delivered audio (the marker holds back 0.5 s, so 8 s fed delivers 7.5 s
and reads presence only). The audio has to stay on its 20 ms grid: a jitter buffer or a codec that delays it off the
grid keeps the presence and by default loses the payload (6 of 100 at 16 kHz); with payload.phase_search: on (ships
off) that cell reads 194 of 200 and G.722 192 of 200. Below 16 kHz streaming marks nothing. Numbers and method:
Audio and ../benchmarks.md, "Voice calls".
A real call with the batch mark: 99 percent (100 Twilio number-to-number calls recorded at the callee, G.711
8 kHz, carrier routing unconfirmed; ../benchmarks.md). For an unkeyed bot, or an utterance under 8 s, provenance on a
phone leg comes from the evidence entry and from C2PA on stored recordings, not from the streaming watermark. The
streaming mark's embed SNR is mean 17.4 dB (5th percentile 8.7 dB), audible on some voices. Method for the other rows:
../benchmarks.md, "Audio robustness".
What a call costs. LaidlineTTSProcessor meters the seconds of audio it pushes on, per utterance. The entry's duration is metered at 0.05 credits a second: 3 credits a minute of bot speech, 15 for a 5-minute call with the bot speaking throughout, 180 an hour. The caller, hold and the gaps between turns never pass through the marker, so they count nothing; silence inside the TTS output counts, speech cut off by a barge-in counts only what left, and a session below 16 kHz counts 0. At monthly prices a 5-minute call costs $0.15 on Lite, $0.05 on Starter, $0.03 on Growth and under $0.01 on Scale; the free allowance of 1,200 credits is 400 minutes. See Licensing.
Dependency note. pipecat-ai 1.12 pins protobuf < 7; pip resolves that for you, and it only matters if
another package in the same environment needs protobuf 7.
What was proven, and how
Unit tests against the real pipecat-ai 1.12.0 package with the real AudioSeal weights: frames built in the test and
driven through Pipecat's run_test and a Pipeline with a sink processor, covering marking, frame fields kept, one
entry per utterance, interruption, cancellation, cleanup and a close cancelled while queued.
A live run on 2026-10-21: pipecat-ai 1.13.0, FastAPIWebsocketTransport with the protobuf
serializer, OpenAI gpt-4o-mini-tts at 24 kHz, the processor between them, and a websocket client recording the stream.
The recorded 10.6 s WAV detected as marked (confidence 0.94; a 5 s slice 0.92; the same text straight from OpenAI,
unmarked, 0.0), and the evidence sink held one entry per utterance (2). For this transport install
pipecat-ai[openai,websocket] and uvicorn, since the websocket extra has no ASGI server. The trial mark is grade D, and each utterance was shorter than the
10 s the payload needs (its floor is 8.0 s).
Troubleshooting
| Message | Cause | Fix |
|---|---|---|
watermark_skipped:rate_below_16k in the entry |
the TTS runs below 16 kHz | run the TTS at 16 kHz or more and resample afterwards |
completed with no audio (Pipecat ErrorFrame) |
the TTS was slower than Pipecat's 3 s stop-frame timeout | non-fatal; the entry is still written |
| entries carry no route | the ctx you passed replaced the default pipecat:tts route |
set MarkContext(route=...) |
audio_unavailable on detect |
laidline[audio] is missing on the detecting host |
pip install "laidline[audio]" there |
a MarkClient is refused |
the streaming path has no HTTP form | pass an in-process Marker |
Reference
- Integrations: what each integration was proven against, and how.
- Audio: the streaming session this processor uses.
- Benchmarks: the audio robustness figures and method.
- Config: the
watermark.audiosection. - Install: extras and the CPU and CUDA torch indexes.