streamdouble 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ """streamdouble -- a framework-agnostic Twilio Media Streams simulator.
2
+
3
+ Test a voice agent's WebSocket endpoint locally, at full protocol fidelity,
4
+ without placing a real call.
5
+
6
+ Not affiliated with Twilio. This package simulates the publicly documented
7
+ Twilio Media Streams protocol.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = ["__version__"]
streamdouble/audio.py ADDED
@@ -0,0 +1,232 @@
1
+ """WAV <-> mu-law conversion and 20 ms frame chunking.
2
+
3
+ Twilio Media Streams carries ``audio/x-mulaw`` at 8000 Hz, mono. One mu-law byte
4
+ is one sample, so a 20 ms frame is exactly 160 bytes. Twilio does not document a
5
+ mandatory frame size, but 20 ms is what it sends and what every implementation
6
+ expects; sending a different size is one of the ways a simulator stops being a
7
+ faithful one.
8
+
9
+ Pure functions and file I/O only -- nothing here touches the network. If a bug
10
+ in this module needs a WebSocket to reproduce, the layering is wrong.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import wave
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+
20
+ from . import g711
21
+
22
+ __all__ = [
23
+ "FRAME_BYTES",
24
+ "FRAME_MS",
25
+ "SAMPLES_PER_FRAME",
26
+ "SAMPLE_RATE",
27
+ "AudioError",
28
+ "frame_ulaw",
29
+ "load_wav",
30
+ "resample",
31
+ "silence_frame",
32
+ "to_mono",
33
+ "ulaw_to_wav",
34
+ "wav_to_ulaw_frames",
35
+ ]
36
+
37
+ #: Twilio Media Streams sample rate, in Hz. Not configurable at the protocol level.
38
+ SAMPLE_RATE = 8000
39
+
40
+ #: Duration of one media frame, in milliseconds.
41
+ FRAME_MS = 20
42
+
43
+ #: Samples in one 20 ms frame at 8 kHz.
44
+ SAMPLES_PER_FRAME = SAMPLE_RATE * FRAME_MS // 1000 # 160
45
+
46
+ #: Bytes in one 20 ms mu-law frame. One byte per sample.
47
+ FRAME_BYTES = SAMPLES_PER_FRAME # 160
48
+
49
+
50
+ class AudioError(Exception):
51
+ """Raised for unusable audio input: bad WAV, unsupported encoding, etc."""
52
+
53
+
54
+ def load_wav(path: str | Path) -> tuple[np.ndarray, int]:
55
+ """Read a PCM WAV file into int16 samples.
56
+
57
+ Args:
58
+ path: Path to a PCM (uncompressed) WAV file.
59
+
60
+ Returns:
61
+ ``(samples, sample_rate)`` where ``samples`` is 2-D with shape
62
+ ``(n_frames, n_channels)`` and dtype int16.
63
+
64
+ Raises:
65
+ AudioError: The file is not readable as PCM WAV, or uses a sample width
66
+ this module does not handle.
67
+ """
68
+ path = Path(path)
69
+ try:
70
+ with wave.open(str(path), "rb") as wav:
71
+ channels = wav.getnchannels()
72
+ width = wav.getsampwidth()
73
+ rate = wav.getframerate()
74
+ n_frames = wav.getnframes()
75
+ raw = wav.readframes(n_frames)
76
+ except wave.Error as exc:
77
+ raise AudioError(f"{path}: not a readable PCM WAV file ({exc})") from exc
78
+ except FileNotFoundError as exc:
79
+ raise AudioError(f"{path}: no such file") from exc
80
+
81
+ if channels < 1:
82
+ raise AudioError(f"{path}: reports {channels} channels")
83
+ if rate < 1:
84
+ raise AudioError(f"{path}: reports a sample rate of {rate} Hz")
85
+
86
+ # Check we actually got the audio the header promised, before handing the
87
+ # bytes to numpy. A truncated file otherwise fails in one of two bad ways:
88
+ # an odd byte count raises a bare ValueError out of np.frombuffer, breaking
89
+ # this module's promise that bad input arrives as AudioError; and an even
90
+ # truncation is worse still, decoding silently to less audio than the caller
91
+ # asked for, which looks downstream like the agent stopped talking.
92
+ # Interrupted downloads and killed recordings both produce exactly this.
93
+ expected_bytes = n_frames * channels * width
94
+ if len(raw) != expected_bytes:
95
+ raise AudioError(
96
+ f"{path}: truncated. Header declares {n_frames} frames "
97
+ f"({expected_bytes} bytes) but the data chunk holds {len(raw)}"
98
+ )
99
+
100
+ samples = _decode_pcm(raw, width, path)
101
+
102
+ if samples.size % channels:
103
+ raise AudioError(
104
+ f"{path}: sample count {samples.size} is not divisible by {channels} channels"
105
+ )
106
+ return samples.reshape(-1, channels), rate
107
+
108
+
109
+ def _decode_pcm(raw: bytes, width: int, path: Path) -> np.ndarray:
110
+ """Convert raw WAV frame bytes of the given sample width to int16."""
111
+ if width == 1:
112
+ # 8-bit WAV is unsigned, offset by 128. Everything else is signed.
113
+ u8 = np.frombuffer(raw, dtype=np.uint8).astype(np.int16)
114
+ return ((u8 - 128) << 8).astype(np.int16)
115
+ if width == 2:
116
+ return np.frombuffer(raw, dtype="<i2").copy()
117
+ if width == 3:
118
+ # 24-bit little-endian packed. Widen to int32 by placing the three bytes
119
+ # in the *high* 24 bits, which sign-extends for free, then shift down.
120
+ b = np.frombuffer(raw, dtype=np.uint8)
121
+ if b.size % 3:
122
+ raise AudioError(f"{path}: 24-bit data length {b.size} is not a multiple of 3")
123
+ b = b.reshape(-1, 3).astype(np.int32)
124
+ i32 = (b[:, 0] << 8) | (b[:, 1] << 16) | (b[:, 2] << 24)
125
+ return (i32 >> 16).astype(np.int16)
126
+ if width == 4:
127
+ return (np.frombuffer(raw, dtype="<i4") >> 16).astype(np.int16)
128
+ raise AudioError(f"{path}: unsupported sample width of {width} bytes")
129
+
130
+
131
+ def to_mono(samples: np.ndarray) -> np.ndarray:
132
+ """Downmix ``(n, channels)`` int16 samples to a 1-D mono int16 array.
133
+
134
+ Averages in int32 so that summing channels cannot overflow.
135
+ """
136
+ if samples.ndim == 1:
137
+ return samples.astype(np.int16, copy=False)
138
+ if samples.shape[1] == 1:
139
+ return samples[:, 0].astype(np.int16, copy=False)
140
+ return samples.astype(np.int32).mean(axis=1).round().astype(np.int16)
141
+
142
+
143
+ def resample(samples: np.ndarray, src_rate: int, dst_rate: int = SAMPLE_RATE) -> np.ndarray:
144
+ """Resample 1-D int16 audio.
145
+
146
+ Uses ``soxr`` for a proper band-limited resample. There is no acceptable
147
+ stdlib resampler: naive decimation aliases badly, and aliasing at 8 kHz
148
+ lands squarely in the speech band, so it would degrade exactly the audio the
149
+ tool exists to test.
150
+
151
+ Raises:
152
+ AudioError: The input is not usable 1-D audio, a rate is not positive,
153
+ or a rate change is needed but ``soxr`` is not installed.
154
+ """
155
+ # Validate at the boundary rather than letting the failure surface later.
156
+ # Multi-channel input reaching here means the caller skipped to_mono; soxr
157
+ # would happily resample it per-channel and hand back a 2-D array, and the
158
+ # eventual complaint would come from g711.encode and appear to blame the
159
+ # codec rather than the missing downmix.
160
+ if samples.ndim != 1:
161
+ raise AudioError(
162
+ f"resample expects 1-D mono audio, got shape {samples.shape}. "
163
+ "Pass it through to_mono() first."
164
+ )
165
+ if src_rate < 1 or dst_rate < 1:
166
+ raise AudioError(
167
+ f"sample rates must be positive, got src_rate={src_rate}, dst_rate={dst_rate}"
168
+ )
169
+
170
+ if src_rate == dst_rate:
171
+ return samples
172
+ try:
173
+ import soxr
174
+ except ImportError as exc:
175
+ raise AudioError(
176
+ f"input is {src_rate} Hz and must be resampled to {dst_rate} Hz, but the "
177
+ "'soxr' package is not installed. Install it with "
178
+ "'pip install streamdouble[resample]', or supply audio that is already "
179
+ f"{dst_rate} Hz mono."
180
+ ) from exc
181
+
182
+ resampled = soxr.resample(samples.astype(np.float32), src_rate, dst_rate, quality="VHQ")
183
+ # Clip before casting: a band-limited resampler can overshoot past full scale
184
+ # on transients, and a bare astype would wrap that around to the opposite sign.
185
+ return np.clip(np.round(resampled), -32768, 32767).astype(np.int16)
186
+
187
+
188
+ def frame_ulaw(payload: bytes, pad: bool = True) -> list[bytes]:
189
+ """Split mu-law bytes into 160-byte frames.
190
+
191
+ Args:
192
+ payload: mu-law encoded audio.
193
+ pad: If true, pad a short final frame to 160 bytes with mu-law silence.
194
+ If false, drop it.
195
+
196
+ Returns:
197
+ A list of frames, each exactly ``FRAME_BYTES`` long.
198
+ """
199
+ frames = [payload[i : i + FRAME_BYTES] for i in range(0, len(payload), FRAME_BYTES)]
200
+ if frames and len(frames[-1]) < FRAME_BYTES:
201
+ if pad:
202
+ short = frames[-1]
203
+ frames[-1] = short + bytes([g711.SILENCE_BYTE]) * (FRAME_BYTES - len(short))
204
+ else:
205
+ frames.pop()
206
+ return frames
207
+
208
+
209
+ def silence_frame() -> bytes:
210
+ """One 20 ms frame of mu-law digital silence."""
211
+ return bytes([g711.SILENCE_BYTE]) * FRAME_BYTES
212
+
213
+
214
+ def wav_to_ulaw_frames(path: str | Path, pad: bool = True) -> list[bytes]:
215
+ """Load a WAV file and convert it to a list of 160-byte mu-law frames.
216
+
217
+ Handles downmixing to mono and resampling to 8 kHz along the way.
218
+ """
219
+ samples, rate = load_wav(path)
220
+ mono = to_mono(samples)
221
+ resampled = resample(mono, rate, SAMPLE_RATE)
222
+ return frame_ulaw(g711.encode(resampled), pad=pad)
223
+
224
+
225
+ def ulaw_to_wav(payload: bytes, path: str | Path) -> None:
226
+ """Decode mu-law bytes and write them to an 8 kHz mono 16-bit WAV file."""
227
+ samples = g711.decode(payload)
228
+ with wave.open(str(path), "wb") as wav:
229
+ wav.setnchannels(1)
230
+ wav.setsampwidth(2)
231
+ wav.setframerate(SAMPLE_RATE)
232
+ wav.writeframes(samples.tobytes())
streamdouble/chaos.py ADDED
@@ -0,0 +1,162 @@
1
+ """Network impairment: what a bad mobile connection does to a call.
2
+
3
+ The happy path is not where voice agents break. They break when the caller is on
4
+ a train, and the tool is only worth having if it can reproduce that.
5
+
6
+ Pure and deterministic: this module decides *whether* to drop a frame and *how
7
+ long* to delay one, and nothing else. It performs no I/O, reads no clock, and
8
+ given the same seed makes the same decisions every run. A chaos feature that
9
+ cannot be replayed is not a test tool, it is a random number generator that
10
+ occasionally fails your build -- when a bad seed finds a real bug, you need to
11
+ be able to hand someone the seed.
12
+
13
+ A note on what impairment can and cannot mean here, because it shapes every
14
+ decision below and the Twilio documentation does not address it.
15
+
16
+ **The Twilio-to-app leg is a WebSocket, which is TCP.** TCP retransmits, so a
17
+ frame cannot simply vanish in transit and cannot arrive out of order. Whatever
18
+ "packet loss" means for a voice call, it does not mean a missing WebSocket
19
+ frame. The loss happens *upstream* of Twilio -- on the carrier's RTP leg, which
20
+ is UDP over a mobile network -- and by the time Twilio comes to build a frame,
21
+ that audio was never received.
22
+
23
+ So the model here is: a dropped frame is one Twilio never sends, because it
24
+ never had the audio. The app sees no gap in ``sequenceNumber`` (Twilio numbers
25
+ what it sends), but it does see ``media.timestamp`` jump by more than one frame
26
+ interval, because presentation time keeps running while the audio does not.
27
+
28
+ **This is an inference, not documentation.** Twilio does not say whether it
29
+ skips the frame or substitutes silence; both are plausible implementations. The
30
+ skip model was chosen because it is the one an agent can actually detect -- a
31
+ timestamp discontinuity is a signal, silence substitution is invisible -- and a
32
+ test tool should surface the harder case. If Twilio turns out to substitute
33
+ silence, this is the knob to change, and the docstring is here so that whoever
34
+ finds out knows where to look.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import random
40
+ from dataclasses import dataclass
41
+
42
+ __all__ = ["Impairments", "Network"]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Impairments:
47
+ """How badly to treat the caller's audio.
48
+
49
+ Defaults are a perfect connection, so an unconfigured session behaves
50
+ exactly as it did before this module existed.
51
+ """
52
+
53
+ #: Probability in [0, 1] that any given frame is never sent.
54
+ loss: float = 0.0
55
+
56
+ #: Maximum timing deviation, in milliseconds, applied per frame. Drawn
57
+ #: uniformly from [0, jitter_ms] -- never negative, because a frame cannot
58
+ #: be delivered before the audio in it was spoken.
59
+ jitter_ms: float = 0.0
60
+
61
+ #: Constant delay added to every frame, in milliseconds. Models distance
62
+ #: rather than instability: a caller on the other side of the world.
63
+ latency_ms: float = 0.0
64
+
65
+ def __post_init__(self) -> None:
66
+ if not 0.0 <= self.loss <= 1.0:
67
+ raise ValueError(f"loss must be a probability in [0, 1], got {self.loss}")
68
+ if self.jitter_ms < 0:
69
+ raise ValueError(f"jitter_ms cannot be negative, got {self.jitter_ms}")
70
+ if self.latency_ms < 0:
71
+ raise ValueError(f"latency_ms cannot be negative, got {self.latency_ms}")
72
+
73
+ @property
74
+ def active(self) -> bool:
75
+ """Whether anything at all is being impaired."""
76
+ return bool(self.loss or self.jitter_ms or self.latency_ms)
77
+
78
+ def describe(self) -> str:
79
+ if not self.active:
80
+ return "none"
81
+ parts = []
82
+ if self.loss:
83
+ parts.append(f"{self.loss:.1%} loss")
84
+ if self.jitter_ms:
85
+ parts.append(f"{self.jitter_ms:g}ms jitter")
86
+ if self.latency_ms:
87
+ parts.append(f"{self.latency_ms:g}ms latency")
88
+ return ", ".join(parts)
89
+
90
+
91
+ class Network:
92
+ """Decides, per frame, whether it is lost and how late it is.
93
+
94
+ Seeded explicitly rather than left to global randomness. When a chaos run
95
+ finds a bug, the seed is the reproduction: without it the failure is a story
96
+ about something that happened once.
97
+
98
+ ``random.Random`` is used rather than ``numpy.random.Generator`` for the same
99
+ reason the fixtures avoid it -- CPython documents the Mersenne Twister
100
+ stream as stable across versions, while NumPy explicitly reserves the right
101
+ to change ``Generator`` on a feature release.
102
+ """
103
+
104
+ def __init__(self, impairments: Impairments | None = None, *, seed: int = 0) -> None:
105
+ self.impairments = impairments or Impairments()
106
+ self.seed = seed
107
+ self._random = random.Random(seed)
108
+
109
+ self.frames_considered = 0
110
+ self.frames_dropped = 0
111
+ self.total_delay_s = 0.0
112
+
113
+ @property
114
+ def active(self) -> bool:
115
+ return self.impairments.active
116
+
117
+ def should_drop(self) -> bool:
118
+ """Whether this frame is lost before Twilio ever sees it."""
119
+ self.frames_considered += 1
120
+ if not self.impairments.loss:
121
+ return False
122
+ dropped = self._random.random() < self.impairments.loss
123
+ if dropped:
124
+ self.frames_dropped += 1
125
+ return dropped
126
+
127
+ def delay_s(self) -> float:
128
+ """Extra delay for this frame, in seconds, on top of its schedule.
129
+
130
+ Constant latency plus a per-frame jitter draw. Always non-negative:
131
+ pulling a frame *earlier* than its deadline would mean delivering audio
132
+ before it was spoken, which no network does.
133
+
134
+ Jitter is applied to a frame's release without moving any other frame's
135
+ deadline, so it perturbs spacing without accumulating -- the pacer's
136
+ absolute-deadline scheduling absorbs it, which is exactly the behaviour a
137
+ real jitter buffer has.
138
+ """
139
+ delay = self.impairments.latency_ms / 1000
140
+ if self.impairments.jitter_ms:
141
+ delay += self._random.uniform(0, self.impairments.jitter_ms / 1000)
142
+ self.total_delay_s += delay
143
+ return delay
144
+
145
+ def summary(self) -> str:
146
+ if not self.active:
147
+ return "no impairment"
148
+ loss_pct = (
149
+ self.frames_dropped / self.frames_considered * 100
150
+ if self.frames_considered
151
+ else 0.0
152
+ )
153
+ mean_delay_ms = (
154
+ self.total_delay_s / self.frames_considered * 1000
155
+ if self.frames_considered
156
+ else 0.0
157
+ )
158
+ return (
159
+ f"{self.impairments.describe()} (seed {self.seed}): "
160
+ f"dropped {self.frames_dropped}/{self.frames_considered} "
161
+ f"({loss_pct:.1f}%), mean added delay {mean_delay_ms:.1f}ms"
162
+ )