mstar.model.audio_watermark#

Opt-in AudioSeal watermark for generated speech.

A deployment turns it on in its YAML model_kwargs:

model_kwargs:
  audio_watermark:
    enabled: true        # default false
    message: 0xBEEF      # 16-bit payload: int 0..65535 or a 16-char bit string
    alpha: 1.0           # watermark strength (AudioSeal's tune-down ratio)

The watermark is Meta’s AudioSeal (MIT code and weights, pip install 'mstar-ai[audioseal]'): a 14.7M-parameter causal conv/LSTM generator whose output, the same length as its input, is added to the waveform. The matching detector scores a clip by the fraction of its samples it finds marked (python -m mstar.model.audio_watermark detect out.wav). Embedding is streaming-exact chunk by chunk; detection on one chunk is not guaranteed. On generated speech even 80 ms chunks scored as marked, but on a synthetic harmonic tone 200 ms slices scored about 0.07 and 1 s slices about 0.8, so detect on whole utterances, or at least a few seconds of audio. The mark is found only at the rate it was added at (24 kHz), so the detector resamples a file at another rate to 24 kHz first.

Streaming. Audio leaves the engine in chunks, and a chunk-by-chunk watermark must equal the watermark of the whole utterance, or the boundaries click. AudioSeal 0.2.0’s model.streaming(batch_size) does not deliver that: its state propagation walks named_children() and stops at the nn.Sequential holding the layers, so no conv caches anything and every chunk is watermarked as if it began the stream (upstream issue #105). This module keeps each request’s stream state itself, as one flat row of tensors, and runs the generator’s layers as a function of it: the same ops AudioSeal’s streaming layers run, with the state passed in and out. That lets requests at the same point share one batched call, and lets the engine capture that call per (batch, chunk length) bucket.

The generator works on whole 320-sample frames (its total stride). A chunk that is not a whole number of frames leaves its tail pending until the next chunk, or until the final chunk zero-pads it, so streamed output equals the offline watermark of the whole utterance to float rounding (on a GPU, to a few int16 steps: cuDNN runs the convolutions in TF32). AudioSeal’s own whole-utterance call pads inside each layer instead, so for an utterance that ends mid-frame the two differ in that last partial frame only (a few tens of int16 steps). Codecs whose chunks are whole frames (Qwen3-TTS: 1920 samples per codec frame) never hold anything back and never pad.

Functions

Classes

AudioSealDetector(detector, no_compile)

AudioSeal's detector: P(watermarked) and the decoded 16-bit message.

AudioSealWatermarker(generator, config, ...)

AudioSeal generator plus per-request streaming, on one device.

AudioWatermarkConfig([enabled, message, ...])

model_kwargs.audio_watermark: per deployment, off by default.

WatermarkStream([caches, pending, finished])

One request's watermark state: the generator's layer caches as one flat row (AudioSealWatermarker.cache_size floats) and the samples of a partial frame not yet watermarked.

class mstar.model.audio_watermark.AudioSealDetector(detector, no_compile)[source]#

Bases: object

AudioSeal’s detector: P(watermarked) and the decoded 16-bit message.

Parameters:

detector (torch.nn.Module)

classmethod build(device='cpu', card=DEFAULT_DETECTOR)[source]#
Parameters:
Return type:

AudioSealDetector

detect(audio, sample_rate=MARK_SAMPLE_RATE)[source]#

1-D float audio at sample_rate -> (fraction of samples scored watermarked, message). The mark is found only at the rate it was added at, so other rates are resampled to MARK_SAMPLE_RATE first.

Parameters:
Return type:

tuple[float, int]

class mstar.model.audio_watermark.AudioSealWatermarker(generator, config, no_compile)[source]#

Bases: object

AudioSeal generator plus per-request streaming, on one device.

The generator runs as a plain function of the audio and every stream’s caches (_mark): the same convolutions, LSTM and adds AudioSeal’s streaming layers would run, with each layer’s state passed in and out instead of kept on the module. Its shapes depend on the batch and chunk length only, so the engine can capture it per (batch, length) bucket (piecewise_configs) and apply_pcm16 replays those captures.

Parameters:
apply(chunks, streams, finals=None)[source]#

Watermark the next chunk of each stream (float [-1, 1], 1-D).

Returns one tensor per chunk: the watermarked audio of every whole frame available so far, which for frame-aligned chunks is the whole chunk. A final chunk flushes the stream’s pending partial frame. Streams at the same frame count run as one batch.

Parameters:
Return type:

list[Tensor]

apply_pcm16(chunks, streams, finals=None, runners=None)[source]#

apply for PCM16 chunks, quantized back the way the codecs do.

Chunks of whole frames with nothing pending (every Qwen3-TTS chunk) run as one call per length, the conversion to float and back included; with runners (the engine’s captures of piecewise_configs) a call whose length was captured replays it, and a final chunk is zero-padded to the next captured length (the generator is causal and the stream’s caches are dropped, so its samples change by float rounding only). Anything else goes through apply.

Parameters:
Return type:

list[Tensor]

classmethod build(config, device)[source]#
Parameters:
Return type:

AudioSealWatermarker

property cache_size: int#

Floats of state per stream.

static capture_label(samples)[source]#
Parameters:

samples (int)

Return type:

str

offline(audio)[source]#

The whole-utterance watermark of 1-D float audio: the reference a stream must equal. Zero-pads to whole frames like a final chunk.

Parameters:

audio (Tensor)

Return type:

Tensor

piecewise_configs(lengths, batch_sizes)[source]#

One PiecewiseBatchedConfig per chunk length (samples, whole frames), for NodeSubmodule.get_piecewise_accelerator_graph_configs: the engine captures _mark_pcm16 per (batch, length) bucket, with the PCM rows and the streams’ caches as static inputs.

Parameters:
Return type:

dict[str, Any]

class mstar.model.audio_watermark.AudioWatermarkConfig(enabled=False, message=0, alpha=1.0, generator='audioseal_wm_streaming')[source]#

Bases: object

model_kwargs.audio_watermark: per deployment, off by default.

Parameters:
alpha: float = 1.0#
enabled: bool = False#
generator: str = 'audioseal_wm_streaming'#
message: int = 0#
message_bits()[source]#

The payload, most significant bit first.

Return type:

list[int]

classmethod parse(value)[source]#
Parameters:

value (Any)

Return type:

AudioWatermarkConfig

class mstar.model.audio_watermark.WatermarkStream(caches=None, pending=None, finished=False)[source]#

Bases: object

One request’s watermark state: the generator’s layer caches as one flat row (AudioSealWatermarker.cache_size floats) and the samples of a partial frame not yet watermarked.

Parameters:
caches: Tensor | None = None#
finished: bool = False#
pending: Tensor | None = None#
mstar.model.audio_watermark.import_audioseal()[source]#