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.
- streamdouble/__init__.py +14 -0
- streamdouble/audio.py +232 -0
- streamdouble/chaos.py +162 -0
- streamdouble/cli.py +485 -0
- streamdouble/g711.py +138 -0
- streamdouble/metrics.py +363 -0
- streamdouble/pacer.py +220 -0
- streamdouble/protocol.py +551 -0
- streamdouble/scenario.py +387 -0
- streamdouble/session.py +808 -0
- streamdouble-0.1.0.dist-info/METADATA +354 -0
- streamdouble-0.1.0.dist-info/RECORD +15 -0
- streamdouble-0.1.0.dist-info/WHEEL +4 -0
- streamdouble-0.1.0.dist-info/entry_points.txt +2 -0
- streamdouble-0.1.0.dist-info/licenses/LICENSE +202 -0
streamdouble/__init__.py
ADDED
|
@@ -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
|
+
)
|