Audio
Read this if you mark speech, music or any audio file, or audio your model streams out chunk by chunk. You will install the audio extra, mark a file, a stream or a stored object, and read the mark back. Skip if you only mark images (Quickstart) or video (Video). Time: 10 minutes for a first file; the keyed mode sections below are reference. Needs: Python 3.12,
pip install "laidline[audio]"and alaidline.yamlfromlaidline init.
In short
- Laidline marks audio with the AudioSeal watermark, signs Content Credentials into containers that can carry them (wav, flac, mp3, m4a) and writes an evidence entry for every mark.
- Unkeyed, the 16-bit payload carries your issuer number only, and detect reports presence only (grade D, no issuer named: the word has no error detection); in keyed mode (what
laidline initsets up) it names the asset and reads from about 10 s of audio (the measured floor is 8.0 s on the 20 ms grid; presence reads from 1.5 s). - One core serves the library,
/v1/mark/audio, the storage hook, the speech proxy and streaming sessions; audio up to 120 s is marked in one call. - The batch mark survives Opus, MP3, AAC and simulated G.711; the unkeyed streaming mark does not survive an 8 kHz phone leg, and below 16 kHz streaming marks nothing. A keyed voice stream reads through G.711, G.726, MP3 and Opus from 12 kbit/s when its audio stays on the 20 ms grid ("Voice calls" below).
- The audio payload and its decoder are built for speech and conversations of 8 s and more; there is no payload under 8 s, and music is a known bound (see "Scope" below).
- The figures are in Benchmarks; the false-positive and sensitivity results of the 2026-10 null campaign are in Benchmarks; the real-call runs are listed at the end of this page.
Laidline marks audio with AudioSeal 0.2.0 (Meta, MIT code and weights). Unkeyed, the mark is a 16-bit payload that
carries your watermark.issuer_id and nothing else; because that word has no error detection, an unkeyed deployment's
detect shows that a mark is present (grade D) and never names an issuer from it. In keyed mode it is the keyed audioseal-p70-r1 payload, which
names the asset (below, "Keyed mode"). Containers that can carry a C2PA manifest (wav, flac, mp3, m4a) are signed as
well (the soft binding com.laidline.audioseal is a Laidline-private name; see Grading), and every mark writes an
evidence entry with modality: audio. The same core serves the library, the
/v1/mark/audio route, the storage hook, the /v1/audio/speech proxy (OpenAI-compatible proxy) and
the streaming plugins (Pipecat, LiveKit Agents).
What survives which codec is measured in Benchmarks. In short: the batch mark survives Opus, MP3, AAC and simulated G.711 (100 percent detection on 500 LibriSpeech clips, 99.6 percent at Opus 12 kbps); the unkeyed streaming mark survives offline Opus, MP3 and AAC transcodes but not a simulated 8 kHz phone leg (0 percent after simulated G.711); a keyed stream's payload reads through it ("Voice calls" below). Over a real Twilio call (number to number, recorded at the callee as G.711 at 8 kHz; carrier routing unconfirmed, possibly on-net) the batch mark was detected in 99.0 percent of 100 calls (95 percent interval 94.6 to 99.8 percent) at 0.39 and 96.0 percent at 0.5, with 0 of 10 unmarked control calls flagged. Over real SignalWire calls (number to number, recorded at the callee at 8 kHz; carrier routing unconfirmed, possibly on-net) the batch mark was detected and read exactly in 21 of 21 calls (the recording aligned to the clip, as the benchmark counts it).
Scope
What the audio mark is built for, and where it is not. The numbers are from the 2026-10 null campaign (measured 2026-10-28, rules sealed before any clip was read; tables in Benchmarks, the grading rules in Grading).
- Speech and conversations of 8 s and more. The keyed payload and its decoder were built and measured on speech: single utterances, joins of 30 s and 120 s, and voice-bot calls. There is no payload under 8 s. A shorter clip carries presence (from 1.5 s) and the payload does not read: across the keyed speech positives, 0 of 167 shorter than 8 s verified, against all 34 of 8 to 10 s and all 469 of 10 s or more. On marked clips of at least 9 s the stream read 196 of 196 and whole-file speech 132 of 132. A 2 s barge-in kept presence and read no payload (0 of 30).
- Speech nulls are low. On 5,000 unmarked units per stratum, an unkeyed deployment showed public presence on 9 single utterances (0.18 %, Wilson upper bound 0.34 %), 4 joins of 30 s (upper 0.21 %) and 12 joins of 120 s (upper 0.42 %). A keyed deployment accepted a tag on 0 of 5,000 in each stratum, 0 of 4,979 music clips and 0 of 26,979 unmarked keyed reads in all (upper bound 0.077 % per stratum). After the streaming-card rule (see Grading) its public presence is 0 on all three speech strata.
- Music is a known bound. The payload is built for speech, and the campaign measured where music falls outside it:
- unkeyed public presence on unmarked music is 361 of 4,979 clips (7.25 %, upper bound 8.00 %); the sealed line was 0.5 %, and it was not met. 360 of the 361 are on the streaming detector card, which an unkeyed deployment still shows;
- keyed, after the streaming-card rule, public presence on unmarked music is 2 of 4,979 (upper bound 0.15 %), a replay of the stored records through the rule;
- keyed whole-file sensitivity on music is 64 % (45 of 70 unedited), because the default read chooses no payload on 33 of the 100 keyed music positives; deep scanning was rejected (above, "Keyed music"). On speech the same arm reads 95 %.
- Unkeyed audio shows presence only. The 16-bit word has no error detection, and 42 of 600 unkeyed positives (40 of them music) read as another issuer's number through bit errors, so an unkeyed deployment names no issuer.
- Other sounds. Reported without a line: sound clips (FSD50K) 191 of 5,000 unkeyed (3.82 %), 15 of 5,000 keyed after the rule; synthetic speech (Kokoro) 2 of 2,000 unmarked (0.10 %).
- Sensitivity lines not met. The sealed line was 97 % on unedited clips. Keyed stream 197 of 210 (93.8 %; all 13 misses are shorter than 8 s); keyed whole file 178 of 210 (84.8 %; speech 95 %, music 64 %); keyed pooled 375 of 420; unkeyed pooled 393 of 420 (93.6 %). Edits: whole-file marks were verified on 77 (86 %) of 90 after MP3 at 128 kbit/s; a stream read 26 of 30 after the MP3 twin and 26 of 30 after Opus at 32 kbit/s.
- Phase search (off by default). With
watermark.audio.payload.phase_search: on(it ships off), pooled over two runs of 100 per cell, a 9.5 s excerpt off the 20 ms grid reads 194 of 200, G.722 192 of 200, a 10 s clip with a delayed start 191 of 200 and AMR-WB 200 of 200; on 26,000 unmarked units it accepted 0 tags and picked a keyless payload twice, both rejected by the tag, which is why it ships off. The shipped default is the search-off numbers: off the grid 6 of 100 at 16 kHz and 4 of 100 at 24 kHz, G.722 and AMR-WB 0 of 100 ("Voice calls" below).
What you need
You need Python 3.12 or newer and the audio extra. For the sidecar route, the speech proxy and the storage hook you
also need the server or ingest extra and a laidline.yaml from laidline init.
# the audio extra stands alone: [image] is not needed pip install "laidline[audio]" # optional: fetch only the AudioSeal weights now (187 MB, SHA-256 pinned) laidline models fetch --no-video --no-image
The weights download on first use into $LAIDLINE_HOME/models (~/.cache/laidline/models without it) and are
checked against the SHA-256 pins that ship in the package: the Hugging Face URLs name repository commit 3c19eba of
facebook/audioseal, not main, a download that runs past the pinned size is stopped, and temporary files a killed download left
behind are removed on the next fetch. With LAIDLINE_OFFLINE=1 a missing or wrong file fails with weights_missing
instead of downloading; laidline models fetch needs LAIDLINE_OFFLINE unset. The audio extra does not install
huggingface-hub: the weights are loaded by local path, so AudioSeal runs with the hub blocked. An image-only install
(laidline[image]) still imports laidline; audio calls there fail with audio_unavailable. An audio-only install
(laidline[audio]) imports on its own: the extra carries Pillow because the core module uses it for the start-up
self-test.
laidline serve does not fetch them at start and /readyz does not wait for them: without models fetch, the first
speech request downloads them inside the request.
Audio watermarking is on by default (watermark.audio.enabled: true) and needs watermark.issuer_id. The configuration
accepts 1 to 65,535: registered issuer numbers are 1 to 32,767, trial numbers (the one laidline init draws) are
32,768 to 65,279, and 65,280 to 65,535 are reserved for documentation, tests and benchmarks
(Issuer numbers). Every key is in Configuration
(watermark.audio).
Set it up
Pick the route that matches where your audio comes from: the library for files and arrays in your own code, the sidecar route for hosts that must not hold keys, the storage hook for objects in a bucket, and a streaming session for audio produced chunk by chunk. Keyed mode applies to all of them.
Library
from laidline import Marker, MarkContext marker = Marker.from_config() # encoded bytes marked = marker.mark_audio(wav_bytes, 0, fmt="mp3", ctx=MarkContext(request_id="job-1")) # float [T] or [C, T] in [-1, 1], or int16 marked = marker.mark_audio(samples, 24000, fmt="wav", ctx=MarkContext()) marked.data, marked.mime, marked.asset_id, marked.manifest_id, marked.warnings
mark_audio(samples, sample_rate, *, fmt, ctx) takes encoded bytes in any container PyAV reads (sample_rate is then
ignored) or an array of 1 to 7 channels at 8 to 192 kHz. It watermarks, encodes to fmt, signs the containers that
can carry a manifest and appends the evidence entry, in that order. mark_bytes(data, mime=..., ctx=...) does the
same for encoded audio and keeps the input's container; an input that already carries a readable manifest becomes the
original ingredient. Audio longer than watermark.audio.max_sync_duration_s (120 s) is audio_too_long with a
failed entry; encoded input is counted while it is decoded, and decoding stops at the cap.
m4a output is written by FFmpeg's ipod muxer (major brand M4A ), so it sniffs as audio. Other tools often write
audio-only MP4 with the isom or mp42 brand, which the brand alone cannot tell from a video: an mp4 with an audio
stream and no video stream (cover art aside) is treated as audio/mp4 by mark_bytes, /detect, ingest and the CLI
unless it is declared as video.
A torch or AudioSeal failure inside the embed is watermark_failed, and weights that cannot be downloaded are
weights_unavailable; both follow on_mark_failure like any other marking failure.
fmt |
Watermark | C2PA manifest | Notes |
|---|---|---|---|
wav, flac, mp3, m4a |
yes | embedded | |
opus, pcm |
yes | none | Warning manifest_skipped:container_unsupported: c2pa-rs cannot embed in these containers. The watermark and the evidence entry are the provenance. |
aac (raw ADTS) |
yes | none | Also warns watermark_payload_unverified:aac: ADTS cannot signal the encoder's 1024-frame priming, which moves the mark off AudioSeal's 20 ms grid, so the mark is detected but its issuer bits do not read back. Use m4a (AAC in MP4) instead. detect recognises ADTS by its sync word, whatever the file is called. |
Audio shorter than watermark.audio.min_duration_s (0.5 s) is signed without a watermark
(watermark_skipped:too_short). A failure follows on_mark_failure like an image mark: fail_closed raises and logs a
failed entry, fail_open returns the re-encoded, unmarked audio and logs an unmarked entry.
The CLI marks by content, else extension, and keeps the container unless --fmt names another:
laidline mark clip.wav # writes clip.marked.wav laidline mark clip.wav --fmt mp3 # writes clip.marked.mp3 laidline detect --json clip.marked.wav
Sidecar: POST /v1/mark/audio
curl -s -o clip.marked.mp3 -D - localhost:8080/v1/mark/audio \ -F file=@clip.wav -F fmt=mp3 -H 'X-Laidline-Request-Id: job-1'
Multipart file plus an optional fmt (default: the upload's own container, else wav). The body is the marked file;
the headers are X-Laidline-Asset-Id, X-Laidline-Manifest-Id (empty when the container carries none),
X-Laidline-Entry-Hash and X-Laidline-Warnings. The request headers X-Laidline-Request-Id, X-Laidline-Tenant,
X-Laidline-Route and X-Laidline-Upstream go into the evidence entry, as on /v1/mark/image (default route
http:/v1/mark/audio).
| Status | reason |
When |
|---|---|---|
| 413 | audio_too_long |
Longer than watermark.audio.max_sync_duration_s (120 s). Refused before decoding when the container headers say so; otherwise the decoded sample count decides (headers can understate it) and a failed entry is written. There is no async path for long audio yet. |
| 422 | undecodable_audio, unsupported_format |
PyAV cannot read the upload, or fmt is unknown. A failed entry is written. |
| 503 | audio_unavailable, or the marking reason |
The audio extra is not installed, or marking failed under fail_closed (tsa_unavailable, sign_failed, ...). |
MarkClient.mark_audio(samples, sample_rate, fmt=..., ctx=...) calls this route from hosts that must not hold keys;
arrays are sent as wav. /detect takes audio uploads as it takes images.
Storage hook
laidline ingest, the Lambda image and the Cloud Run receiver mark audio objects whose type is in
ingest.mime_allowlist. The default allowlist is images only; add the audio types you store:
ingest: mime_allowlist: [image/png, image/jpeg, image/webp, audio/wav, audio/flac, audio/mpeg, audio/mp4]
Aliases (audio/x-wav, audio/mp3, audio/x-m4a, ...) are read as their canonical types. The object is written back
in its own container. undecodable_audio and audio_too_long are not retried. The container images include the audio extra (about
150 MB more on disk than without it; Benchmarks); they do not carry the AudioSeal weights,
which download on the first audio object into $LAIDLINE_HOME/models (/tmp on Lambda, so each new execution
environment downloads them again). An ingest mark holds the whole object in memory; keep ingest.max_bytes and
ingest.visibility_timeout in line with your longest file.
Streaming sessions
For audio produced chunk by chunk (TTS streams, voice agents), a session marks each chunk as it passes and writes one evidence entry when it closes:
session = marker.audio_stream(MarkContext(route="tts:support"), sample_rate=24000, channels=1) # interleaved s16le for pcm in tts_chunks(): # same length out; a trailing partial frame carries to the next call send(session.process(pcm)) # one `marked` entry; closed.entry_hash closed = session.close(mime="audio/pcm")
process(pcm16)returns the same number of frames, marked;process_array(chunk)takes float[C, n].sample_ratemust be 8000 to 192000 andchannels1 to 8 (undecodable_audiootherwise). Below 16 kHz nothing is marked: the streaming mark does not survive there (detection 0.0 at 8 kHz), so the session passes the audio through unchanged and its entry haswatermark: nulland the warningwatermark_skipped:rate_below_16k. An 8 kHz phone bot keeps working but gets no watermark from streaming.- Each call returns as many frames as it received, so the mark adds no latency. The first 20 ms (16 kHz input) or 40 ms (other rates) and the tail after the last whole 20 ms block leave unmarked.
- The entry has
watermark.streaming: true,c2pa: nulland the warningmanifest_skipped:streaming: a stream has no container to sign.close(mime=, sha256_out=, size=)records what you actually sent on (default: the marked pcm16). close(twin_fmt="mp3")(or wav, flac, m4a) also signs the whole marked stream as a twin file and records it undertwinin the entry. Chunks are kept in memory for it up tomax_sync_duration_s; a longer stream getstwin_skipped:too_long. A twin that fails to sign is atwin_failed:<reason>warning; the entry is written anyway.detecton the twin resolves the stream's entry (its manifest names the asset, and the entry'stwin.sha256confirms the bytes:matched_by: twin_bytes).- A session is for one stream; sessions run concurrently on the shared model.
- Keyed mode (below) holds frames back:
processreturns fewer frames than it received (none at first: the first marked frame arrives after 0.70 s of audio, the embedder's look-ahead of 10 blocks plus a hop of 25 blocks of 20 ms), and the last 0.5 s of the stream is held back and comes out offlush()(or asclose().tail), marked. Send whatflush()returns before the end of the stream. If the stream is cut short (a barge-in, a disconnect), do not flush: callclose(discard_tail=True)and the entry, its metered seconds and its fingerprint record cover only what you sent. The speech proxy,laidline-livekitandlaidline-pipecatdo this for you.
The streaming embedder is AudioSeal's streaming card, each 20 ms block on its own (watermark.audio.stream_kind: block, the only value; the key stays so existing configurations keep loading). A variant that gives the card 1000 ms
of left context is not offered: it survived G.711 on synthetic tones, but on real speech it was detected in only
22.4 percent of clips even without a codec, and it ran slower than real time on a CPU.
The unkeyed streaming mark is louder than the batch mark (embed SNR mean 17.4 dB, 5th percentile 8.7 dB, against 26.6 dB for the batch mark on the same speech) and does not survive simulated G.711 or an 8 kHz round trip. For audio that will cross a phone network, mark the file in batch mode where you can; for live phone calls, the evidence entry is the record. The keyed buffered stream is a different embedder and is measured separately under "Voice calls".
Keyed mode
With watermark.payload_keys configured (laidline init does it), audio marks carry audioseal-p70-r1: 70 bits
naming your issuer and the asset, with a 24-bit key tag, written at alpha 1.41 (Payload keys has the layout
and what a key does and does not do). What changes:
- Length. The claim is 10 s. Presence reads from 1.5 s of audio; the payload reads from 8.0 s delivered on the mark's
20 ms grid (one P70 cycle), which a stream fed 10 s meets (the marker holds 0.5 s back until close, so a barge-in
needs at least 8.5 s fed). Shorter audio is marked all the same, but its payload does not read
(
payload_read: too_short) and it grades D at most. Keyed audio is marked up to 120 s: a keyed configuration withmax_sync_duration_sabove 120 is refused at start (the fingerprint record's length). - Containers. Keyed
aac(ADTS) output is padded with a few hundred frames of silence so the payload reads (pad_samplesin the entry), and no longer getswatermark_payload_unverified:aac.m4a,mp3,opus,flacandwavneed no pad. A keyed mp3 stream from the speech proxy is padded the same way, because a streamed mp3 carries no header for its encoder delay. - Streams. They use a buffered embedder: the first marked frame arrives after 0.70 s of audio and 0.5 s is held back until close (discarded on a barge-in, so a barge-in delivers the fed length minus 0.5 s); see "Streaming sessions" above. A keyed stream marks every frame, the first ones included.
- Detect. The default read (stage 1, CPU) reads the payload from a file whose start is intact: the file as
Laidline wrote it, or an excerpt that starts on a 20 ms boundary of it. Trimmed, offset or excerpted audio needs
deep scanning (
watermark.audio.payload.hardened_read, off by default, GPU-recommended); off the grid the read of the 2026-10-27 campaign kept presence and lost the payload (numbers under "Voice calls"). The phase search of Payload keys reads such audio under the hardened decode, but it is off by default (watermark.audio.payload.phase_search; the v2 matrix numbers and the reason are under "Voice calls"). A payload grades B only when the content confirms it: the file's exact bytes (or the signed twin's), or the registered asset's fingerprint record (from 10 s of marked speech); C (full tier) when registered but not confirmed, and D otherwise. - Public tier. The public scanner shows only whether a Laidline audio watermark is present, and says so when it is another deployment's. It shows no payload, issuer or grade above D. It also does not show a presence that only the streaming detector card gave: Laidline's keyed marks are written by the batch generator, which that card does not read, and on unmarked audio that card gave 360 of the 361 public false presences on music, all 25 on read speech and 177 of the 192 on sounds. With the card hidden, a replay of the campaign's records gives music 2 of 4,979 (from 361, the 7.25 % unmarked-music rate of Round M), read speech 0 (from 25) and sounds 15 of 5,000 (from 192), with 0 of the positives losing public presence (Grading).
- Voice calls. Rows for a voice bot's streams, codecs, loss, noise and reverb are under "Voice calls" below.
- Evidence. The entry records the payload fields and a commitment to a fixed-size fingerprint record stored beside it (Evidence log).
The CPU standard (every deployment). In keyed mode Laidline writes an asset-naming payload into audio. From a file whose start is intact (the file as Laidline wrote it, or an excerpt that starts on a 20 ms boundary of it), the payload reads on CPU from 10 seconds of speech through:
- Opus (Ogg) from 12 kbit/s;
- MP3;
- AAC in MP4 or M4A (players that honour the encoder delay);
- raw AAC (ADTS) as Laidline writes it;
- G.711.
On read speech (half clean read speech, a quarter harder read speech, a quarter a second clean corpus; 200 clips), every codec read 100 % except Opus at 12 kbit/s, with 0 wrong payloads in 7,600 reads. At Opus 12 kbit/s (post-stratified):
| envelope | read | interval | test-clean | test-other | dev-clean |
|---|---|---|---|---|---|
| 10 s excerpt on the 20 ms grid | 97.3 % | 85.5 to 99.2 % | 35 of 35 | 14 of 15 | 144 of 150 |
| whole file | 97.0 % | 84.7 to 99.2 % | 33 of 35 | 15 of 15 | 149 of 150 |
On the 89 of those clips never used before, Opus 12 kbit/s read 98.6 % on the excerpts (74.7 to 99.5 %) and 91.3 % on whole files (66.0 to 97.6 %, too few clips to rely on). The 200-clip figures are the measure.
Poor-quality audio (noisy, reverberant or heavily compressed recordings) reads below this standard. Keyed audio is marked at alpha 1.41, which Laidline sets. Raw AAC or untagged MP3 written by other encoders counts as edited.
Edited audio (trimmed, offset or excerpted at any point; Benchmarks) shows presence on a CPU-only host, where audio.decode: auto resolves to the fast read
at 15.5 % (Opus 12 kbit/s) to 83 % (MP3 128 kbit/s), and 82 % unchanged. Under decode: hardened the full tier shows
presence at 91 to 100 %, and the public tier at 17 to 84 %. Where the payload is read and names a registered asset,
the asset's fingerprint record can confirm it (grade B by fingerprint; Grading).
Marks inside longer audio (keyed P70, decode: hardened). watermark.audio.decode defaults to auto: hardened
when the audio detector runs on a CUDA device, fast otherwise. Under the default watermark.device: auto, a host with
a usable GPU runs AudioSeal detection on it, while marks are still embedded on the CPU. Under hardened, a keyed deployment finds about one
second of marked audio anywhere in a recording:
- 1 s in 10 s: 100 of 100;
- 1.5 s in 30 s: 98;
- 6 s in 120 s: 100;
- through Opus 24 kbit/s, 92 (3 s in 30 s) and 96 (12 s in 120 s).
Half a second is reported, not promised (80 and 69). Unmarked audio gains no flag from this, and such a presence is
shown on the full tier only unless it is confirmed. The hardened decode is GPU-recommended: on 2 CPU threads it costs
about 41 to 54 s per clip. Under the hardened decode the payload of a mark inside a longer recording is read too,
without deep scanning (watermark.audio.payload.span_trigger, on with the hardened decode), measured at 12 s inside
120 s (98 of 100; 93 through Opus 24 kbit/s), 24 s inside 120 s (98) and 9 s inside 30 s (98, reported) (Grading).
The GPU standard (deep scanning enabled on a GPU). With deep scanning enabled on a GPU (hardened_read, off by default),
the payload also survives:
- trims and excerpts at any start sample, and material prepended to the file;
- from 10 seconds, through Opus from 24 kbit/s, MP3, AAC, G.711 and common AAC and MP3 containers.
It read 200 of 200 on every such row (98.1 to 100 %). Through Opus at 12 kbit/s it is best effort: 94.0 % measured (188 of 200, 89.8 to 96.5 %).
Keyed music. The figures above are speech. For music the issuer payload is read on 64 % of marked 30 s music clips
(45/70 unedited, 19/30 after MP3 at 128 kbit/s); the rest show presence without an issuer. Deep scanning
(payload.hardened_read) is not supported for music: in the add-on run it recovered none of the 33 unread positives,
chose 26 payloads on 4,979 unmarked music clips (none verified) and labelled 3 positives with another issuer's number, so
hardened_read: auto stays off for music.
What the payload is not. A verified payload proves the mark was made under this deployment's key for asset X. A
copied mark verifies on any audio it is pasted onto, so grade B needs the content to match: the file's bytes (or the
signed twin's), or the registered asset's fingerprint record (confirmed_fingerprint for the whole
file, contains_registered_asset with a region for part of it). A payload copied onto other audio verifies but does
not confirm: on 1,000 such forgeries the measurement gave grade C on all 1,000 and B on none (Benchmarks).
Whole file or region. The fingerprint query spans the read span plus 4 s, not the whole clip. In the 2026-10 null
campaign the scope was the whole file (confirmed_fingerprint) for most single utterances and a region
(contains_registered_asset) for every 30 s and 120 s positive and 60 of 64 music positives, so an unedited copy of a
registered 30 s or 120 s asset is reported as containing a registered region. That is correct but understated.
Public scanner. It reports whether a Laidline audio watermark is present. It shows no payload, issuer or verification detail. A watermark of another deployment is reported as present and as not this deployment's mark.
Limits. The payload does not carry through these:
- reverb (re-recorded in a room: presence, usually no payload);
- a music bed at 10 dB;
- white noise below about 22 dB SNR (the payload is lost half the time there; babble reads 94 % at 20 dB);
- tempo changes;
- Opus at 8 kbit/s, Speex and AMR-NB; G.722 and AMR-WB are delay-limited ("Voice calls").
Below 8.0 s delivered on the 20 ms grid there is no payload, and a start off the grid loses it too in the 2026-10-27 read (see "Voice calls"). "Another deployment's mark" needs a foreign payload read on three or more grids (Payload keys; a rule chosen after the measurement, because two grids labelled one own mark at Opus 12 kbit/s under deep scanning).
The full tables are in Benchmarks.
Check it worked
Mark a short speech clip (10 seconds or more, so a keyed payload can read) and read it back:
laidline mark clip.wav
Expected output:
{
"out": "clip.marked.wav",
"asset_id": "ast_01M5VGW9BMKKZG8PWAJZP1M015",
"manifest_id": "urn:c2pa:4807528d-2b68-460c-b224-bc3018ca13e0",
"entry_hash": "03bbf0f3c0fa57d28963e62d63d2a2532528390d9fb64d65a6aa571d00b945e5",
"warnings": []
}
What just happened: Laidline watermarked the samples, wrote clip.marked.wav, signed a Content Credentials manifest
into it and appended an evidence entry. The first run downloads the AudioSeal weights unless you fetched them ahead.
laidline detect clip.marked.wav
Expected output:
verdict: marked_by_this_system grade: A
c2pa present=True grade=A status=valid_trusted
watermark present=True grade=D status=shared mark
issuer: shared mark, provider not identified
note: Absence of a signal is not evidence that content is human-made.
note: Revocation status was not checked (OCSP disabled in this deployment).
The c2pa line shows your manifest chaining to your own anchor, and the watermark line shows the AudioSeal mark
found. The watermark grades D here because the clip was marked under a trial issuer number, which is shared; after
registration it reads as your own mark. The ids differ on every run.
Detection and the operating threshold
detect decodes with the batch detector and, if that finds nothing, the streaming one. The watermark signal is
present when AudioSeal's score (confidence) reaches watermark.audio.detect_threshold, and names the card that
fired (detector). Because the unkeyed payload carries the issuer only and cannot be checked:
- an unkeyed deployment reports presence only: grade D, no
payload, no issuer named, whatever the 16 bits read (the word has no error detection); it never resolves an asset (registry_match: null), and the caveat "an AudioSeal mark names no asset; the file is not resolved against the evidence log." is added; - only a C2PA manifest trusted by your own anchor gives
marked_by_this_systemand theevidence_logentry (Grading). opus, aac and pcm files carry no manifest, so their best verdict issignals_present_unverified.
The threshold: the configuration default is 0.5. The benchmark's operating point is 0.39, the lowest threshold
with a false-positive rate below 1 percent on 530 unmarked clips (an earlier run: 0.75 percent; 0.38 percent at 0.5). At the
shipped 0.5 the 2026-10 null campaign measured an unkeyed deployment's public presence on 5,000 unmarked units per stratum at 0.18 percent of single
utterances, 0.08 percent of 30 s joins and 0.24 percent of 120 s joins, and 7.25 percent of music ("Scope" above). The
marking-method text of the evidence export quotes detection at the deployment's detect_threshold (the benchmark counted at
0.39 and at 0.5; any other value is quoted at 0.39 and said to be unmeasured), adds the streaming mark after Opus and
after a phone leg, and the real phone-call loopback (Twilio; batch mark). Set detect_threshold: 0.39 for more detection after heavy
degradation; keep 0.5 for fewer false positives (see the at 0.5 column in Benchmarks).
detect refuses audio longer than watermark.audio.max_detect_duration_s (120 s by default) plus 1 s of decode slack
with an error result (audio_too_long); decoding stops there. The slack covers codec padding: Laidline's own 120 s
m4a or aac decodes to about 120.02 to 120.06 s. The detector's memory grows with the length (measured on a CPU
host: about 1.1 GB of peak RSS for 120 s, 2.7 GB for 300 s), so raise the cap only on hosts with the memory for it.
Measuring a phone leg yourself
The telco driver in laidline-bench (pip install laidline-bench) plays a marked clip into a real call from number A
to number B on SignalWire (or Twilio), records B's leg and decodes the recording; the package's robustness README has
the commands. What the SignalWire runs needed:
- The provider named. Set
LAIDLINE_TELCO_PROVIDER=signalwirewhen Twilio credentials are in the environment as well; unset, the driver picks SignalWire, and a teardown meant for the other provider undoes the wrong record. - A paid project. The loopback needs two numbers and a trial project holds one: add a payment method and at least $5 of credit first. A number can be released only after 14 days, with 2 early releases a month.
- A region in the number search. The unfiltered US local search returned no numbers (and for a while answered 503
"could not be completed"); the driver searches with
InRegion=FL. - The bucket's region. The driver presigns the clip URLs on the bucket's regional S3 endpoint
(
LAIDLINE_IT_REGION); the global endpoint answers a new bucket with 307, then 403. - The receiver checked first. B's voice URL must answer with a
<Record>echo;runfetches it once before any call.
Rate limits: SignalWire documents 1 call per second for voice (a call over it fails with error 20429) and 10 simultaneous queued calls on a trial project. It also refused every call after the 21st in one day with
failure_reason: rate_limited, a cap it does not document.
Voice calls
What a voice bot can promise, from the 2026-10 voice-call matrix (10,200 units; 100 per cell; the keyed buffered stream
marker, then a simulated path, then Marker.detect on a GPU with the shipped defaults of 2026-10-27; the tables and
the method are in Benchmarks). "Payload" means the tag verified and the
payload is the one written. The codecs are simulated, not carrier paths; real phone legs are listed under "What was
proven, and how".
Session
- 16 or 24 kHz sessions, chunks of 20 ms or more. Below 16 kHz the session passes the audio through unmarked. A 10 ms chunk misses its compute budget on every 320-frame block boundary (1.6 % of chunks, every unit); 40 and 100 ms chunks missed none.
- Latency: the first marked frame leaves after 0.70 s of audio (0.72 s with 40 ms chunks or at 24 kHz). The marker holds 0.5 s back and releases it at close; a barge-in drops it. Real-time factor 0.024 to 0.026 on an RTX 5080.
What a call costs. A session's one entry records watermark.duration_s, the seconds of audio that left the marker, and
the meter prices it 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 your TTS output counts, speech cut off by a barge-in counts only what left (discard_tail),
and a session below 16 kHz has watermark: null and 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, about 80
such calls. The table and the plans are in Licensing.
Length
| what | value |
|---|---|
| presence | from 1.5 s delivered (100 % public presence in every cell from 2 s fed) |
| payload | from 8.0 s delivered on the 20 ms grid: 100 of 100 at 8.0 s at 16 and 24 kHz, 0 wrong; 7.5 s reads nothing |
| through the stream marker | 10 s fed delivers 9.5 s: 100 of 100 at both rates; 8 s fed delivers 7.5 s: 0 of 100. A barge-in at 16 kHz needs at least 8.5 s fed (100 of 100). A 24 kHz stream needs about 9 s fed (inferred from the 8.5 s and 10 s cells): it holds back 501 ms, so 8.5 s fed delivers 7.999 s, under the 8.0 s floor (0 of 100) |
| the claim | 10 s |
Alignment. The payload reads when the audio keeps its 20 ms grid. With the phase search off (the default, and the
2026-10-27 run), a clip cut at a sample off the grid keeps presence (77 to 81 %) and loses the payload (6 of 100 at
16 kHz, 4 of 100 at 24 kHz, against 100 of 100 on the grid); a delay of 2 to 480 samples that is not a whole 320-sample
block reads 0 of 50 (5 samples: 9 of 50), and whole blocks read 50 of 50. A jitter buffer or a codec that delays the
audio therefore costs the payload and keeps the presence. With watermark.audio.payload.phase_search: on (hardened
decode; v2 matrix of 2026-10-30, pooled over two runs, n 200 per cell) a 9.5 s excerpt off the grid at 16 kHz reads 194 of
200, a 10 s clip with a delayed start 191 of 200, G.722 192 of 200 and AMR-WB 200 of 200. It is
off by default because, on 26,000 unmarked units, it chose a keyless payload twice (tag accepts: 0) where the
pre-registered line allowed none, and because on an L4 GPU the detect time ran 7 to 10 % over its budget in five of 74 cells (pooled count; on the L4
alone G.722 is also over, at 1.07; since 2026-10-30 the latency line is bound to the A10G and the L4 numbers are recorded
as a deviation). No wrong payload was shown on any of the 6,850 marks; 27 of them produced an internal keyless pick that
the tag rejected (read strictly, as the unmarked rows are, that line fails; both numbers are in the campaign report). Its
cost is about +0.5 s per read at p50.
Detect time. Marker.detect on a 10 s clip, p95 within 0.1 x duration + 1.5 s on an A10G in every cell of the v2
matrix (worst 0.92 of the budget; the matrix ran with the search on); on an L4 the five search cells (AMR-NB, AMR-WB, the
delayed clip, both off-grid excerpts; G.722 as well when the L4 is taken alone, 1.07 of the budget) ran 7 to 10 %
over it.
Codecs (payload at 10 s fed; every 4 s cell reads no payload). The rows are the v2 matrix of 2026-10-30
(100 per cell) with phase_search: on unless a row says otherwise; the cells that keep the 20 ms grid do not use the
search, so G.711, G.726, Opus at 16 kbit/s and above and the MP3 twin read the same with it off.
| codec | payload |
|---|---|
| G.711 mu-law and A-law | yes, 100 of 100 each |
| G.726 at 32 kbit/s | yes, 100 of 100 |
| Opus at 32, 24, 16 kbit/s | yes, 100 of 100 each |
| Opus at 12 kbit/s | yes: 98 of 100 as shipped; 99 of 100 with payload.phase_search: on (ships off) |
| MP3 (the signed twin) | yes, 100 of 100 |
| Opus at 8 kbit/s | no: it destroys the mark (5 of 100 with the search on, 0 with it off) |
| Speex | no: CELP does not keep the waveform (0 of 100) |
| AMR-NB at 12.2 kbit/s | search on: 91 of 100; search off: 0 (its codec delay is 80 to 83 samples) |
| G.722 | search on: 98 of 100 (0 with it off: the codec delays the audio by 22 samples) |
| AMR-WB at 23.85 kbit/s | search on: 100 of 100 (0 with it off: the codec delays the audio by 95 samples) |
| G.729, EVS | not measured (no encoder available) |
Loss, noise and rooms
| condition | payload |
|---|---|
| packet loss up to 10 %, repeat-last or zero fill | 100 of 100 in every cell |
| white noise at 30, 25, 20 dB SNR | 99, 83, 30 of 100; half the payloads are lost at about 22 dB |
| babble at 30, 25, 20 dB SNR | 99, 100, 94 of 100 |
| reverb at 0 dB direct-to-reverb ratio | presence (94 % public), usually no payload (8 of 100 at RT60 0.3 s; 3 to 13 % across RT60 0.1 to 0.5 s) |
Audio re-recorded in a room keeps presence and usually loses the payload; the ratio matters more than the RT60 (at RT60 0.3 s, +6 dB reads 67 % and +12 dB reads 100 %). Reverb followed by G.711 reads 3 %.
Unmarked calls. Public presence on 4,000 unmarked units (G.711, Opus 12 kbit/s, AMR-NB, 5 % loss; 1,000 each): at most 1 per row, 0 tag accepts.
Wrong payloads. 6 of 6,200 marked units read a payload of another issuer number (reverb, white noise at 20 dB, G.722). Their tag failed under every key; no tier shows a payload whose tag failed.
Limits
- Scope: speech and conversations of 8 s and more; music is a known bound; no payload under 8 s ("Scope" above).
- Unkeyed, the watermark shows presence only: no asset and no issuer (grade D at most). In keyed mode the payload names the asset but needs about 10 s of audio (8.0 s delivered on the 20 ms grid is the measured floor; presence reads from 1.5 s). Under the hardened decode a mark inside a longer recording is read by the span read (measured at 9 s in 30 s and at 12 and 24 s in 120 s); shorter marks, and an edited start without such a mark, need deep scanning on a GPU.
- Cropping by part of a 20 ms block (for example 10 frames at 16 kHz) can flip issuer bits while detection mostly
survives; the same effect makes raw
aacoutput unverifiable. The hardened decode's phase search reads the issuer off the 20 ms grid; only the fast read cannot. - opus, aac and pcm outputs, and every stream, carry no C2PA manifest. For streams, the proxy's signed twin is the stand-in.
- Tempo changes remove the batch mark (1.4 and 2.0 percent at x0.9 and x1.1), Mimi removes both marks, and EnCodec keeps detection but not the issuer.
- 8 or more channels are not supported for files and arrays (PyAV cannot build such frames); streaming sessions take up to 8.
- Streaming below 16 kHz marks nothing (
watermark_skipped:rate_below_16k). - Audio longer than
max_sync_duration_sis not marked, and longer thanmax_detect_duration_snot detected. The hardened decode reads the best 1 s window at its own calibrated threshold. - Uploads to
/v1/mark/audioand/detectare capped atserver.max_upload_bytes(64 MiB by default); a larger body is a 413.
The codec figures here come from Benchmarks and Robustness; the real-call runs are listed under "What was proven, and how" below.
What was proven, and how
- Library, route, ingest, streaming sessions and detection: run against the real AudioSeal weights on CPU, with a reference case for each of wav, flac, mp3 and m4a.
- Robustness: 500 LibriSpeech test-clean clips and 30 Open Goldberg Variations excerpts through 19 transforms in batch, streaming and a context variant that is not shipped (Benchmarks).
- The speech proxy: live against api.openai.com (OpenAI-compatible proxy).
- A real phone leg, batch mark only, recorded on 27 Sep 2026: 100 Twilio number-to-number calls recorded at the callee (G.711 8 kHz; carrier routing unconfirmed, possibly on-net): detection 99.0 percent at 0.39 and 96.0 percent at 0.5, issuer exact 98.0 percent, 0 call errors, 0 of 10 unmarked control calls flagged (Benchmarks).
- A real phone leg on SignalWire, batch mark, run on 21 and 22 Oct 2026 (the benchmark's unkeyed 16-bit mark, not the
keyed
audioseal-p70-r1mark of alaidline initdeployment): 21 number-to-number calls recorded at the callee (8000 Hz; carrier routing unconfirmed, possibly on-net). All 21 were detected at 0.39 and at 0.5, and all 21 read the exact payload, counted on the recording aligned to the clip; in the 20-call run the whole untrimmed recording was detected in 17 of 20 at 0.39 and 16 of 20 at 0.5, with no exact read, so alignment matters. The phone-path false-positive figure is 0 of 10 on Twilio. The benchmark's streaming mode measures the unkeyed 16-bit streaming mark (AudioSealWatermarker.stream), not the keyedaudioseal-p70-r1stream of alaidline initdeployment.
Troubleshooting
| Message | Cause | Fix |
|---|---|---|
audio_unavailable |
The audio extra is not installed (an image-only install imports, but cannot mark audio). |
pip install "laidline[audio]" |
weights_missing |
LAIDLINE_OFFLINE=1 is set and the AudioSeal files are absent or fail their SHA-256 check. |
Unset LAIDLINE_OFFLINE and run laidline models fetch --no-video --no-image. |
weights_unavailable |
The weights could not be downloaded. | Check network access to Hugging Face, or fetch ahead on a host that has it. The failure follows on_mark_failure. |
audio_too_long |
The audio is longer than watermark.audio.max_sync_duration_s (120 s) for marking, or max_detect_duration_s for detection. |
Split the audio. Marking is refused with a failed entry; there is no async path for long audio. |
undecodable_audio |
PyAV cannot read the upload, or a streaming session got a rate outside 8000 to 192000 or channels outside 1 to 8. | Convert the file to wav or flac, or fix the session arguments. |
manifest_skipped:container_unsupported |
opus and pcm cannot carry a manifest. |
Expected. Use wav, flac, mp3 or m4a if you need Content Credentials in the file. |
watermark_payload_unverified:aac |
Raw ADTS output moves the mark off AudioSeal's 20 ms grid, so the issuer bits do not read back. | Write m4a (AAC in MP4) instead, or use keyed mode, which pads the output. |
payload_read: too_short |
A keyed payload needs 8.0 s delivered on the 20 ms grid; the claim is 10 s (a barge-in costs 0.5 s). | Mark longer audio; the watermark is still present. |
watermark_skipped:rate_below_16k |
A streaming session below 16 kHz marks nothing. | Mark the file in batch mode where you can; the evidence entry is the record for live calls. |
Reference
- Configuration: every
watermark.audiokey. - Grading: how an audio watermark and a manifest become a grade.
- Payload keys: the keyed audio layout and what a key does and does not do.
- Benchmarks: every figure on this page.
- OpenAI-compatible proxy, Pipecat, LiveKit Agents: the speech proxy and the streaming plugins.