Audio runs on the mock backend

Goal: explain a message that holds speech, with a backend that reacts to the audio it hears, without any model.

Why it matters: audio pipelines need deterministic, offline tests; a backend that ignores audio would hide masking bugs behind constant outputs.

Prerequisites: the core install only; the clip is synthesised as 16-bit WAV.

Output: the heard tokens of a clip, how silencing one word changes them, and an explanation run through mock_audio.

import io
import wave

import numpy as np

from mllm_shap import (
    AudioPart,
    ConversationSnapshot,
    ExplainConfig,
    ExplainRunner,
    Message,
    MockAudioBackend,
    Role,
    TextPart,
    Turn,
)
from mllm_shap.domain.config import GenerationConfig

A clip with two spoken words

Two tone bursts stand in for two words, separated by 50 ms of silence. speech writes 16-bit WAV with the standard library, which mock_audio reads without the audio extra.

RATE = 8000


def speech(amplitudes: list[float]) -> bytes:
    t = np.arange(RATE // 20) / RATE
    waveform = np.concatenate([a * np.sin(2 * np.pi * 300 * t) for a in amplitudes])
    buffer = io.BytesIO()
    with wave.open(buffer, "wb") as writer:
        writer.setnchannels(1)
        writer.setsampwidth(2)
        writer.setframerate(RATE)
        writer.writeframes((waveform * 32767).astype("<i2").tobytes())
    return buffer.getvalue()


def snapshot(audio: bytes) -> ConversationSnapshot:
    spoken = Message(
        role=Role.USER, content=(TextPart("hello there"), AudioPart(audio, RATE))
    )
    return ConversationSnapshot.from_turns((Turn(messages=(spoken,)),))

What the backend hears

The answer echoes the message words, then adds one token per voiced run. Silencing the second word drops only its token; max_new_tokens truncates the answer.

backend = MockAudioBackend(model_id="demo")


async def heard(audio: bytes, max_new_tokens: int = 16) -> str:
    record = await backend.generate(
        snapshot(audio), GenerationConfig(max_new_tokens=max_new_tokens)
    )
    return record.text


print("both words  :", await heard(speech([0.6, 0, 0.4])))
print("second muted:", await heard(speech([0.6, 0, 0])))
print("truncated   :", await heard(speech([0.6, 0, 0.4]), max_new_tokens=3))
both words  : hello there a32e 14a3
second muted: hello there a32e
truncated   : hello there a32e

An explanation through mock_audio

backend.kind: mock_audio selects the backend from the config. Here the words of the transcript are players; with players: sgpa_segments the spoken words are players and the absence policy edits the audio the backend hears (see the alignment guide).

config = ExplainConfig.model_validate({
    "backend": {"kind": "mock_audio", "model_id": "demo"},
    "explanation": {"archive": {"path": ""}},
})
output = await ExplainRunner(config).explain(snapshot(speech([0.6, 0, 0.4])))
result = output.result
print([round(v, 4) for v in result.values])
print("efficiency gap:", round(sum(result.values) - (result.grand_value - result.base_value), 12))
[0.2101, 0.2101]
efficiency gap: 0.0

Audio it cannot read

Bytes that are not 16-bit WAV go to the audio extra’s decoder: undecodable bytes raise ValueError, and without the extra an ImportError names it.

try:
    await heard(b"not audio")
except (ValueError, ImportError) as error:
    print(type(error).__name__, "-", error)
ValueError - could not decode the audio bytes