Rooms and channels¶
kudio.core.room
¶
Rooms: describe one, apply it, measure what it did.
Noise is additive: kudio.Synthesizer mixes it at a chosen SNR, and
kudio.spectral_enhance takes it back out. Reverberation is the other
kind, and it is convolutive: the room does not add anything, it smears what
was already there across time. This module describes a room, puts a clip in
it, and measures one from its impulse response.
>>> ir = kudio.rir(16000, rt60=0.6, drr_db=6.0, seed=0)
>>> wet = kudio.apply_rir(speech, ir)
>>> print(kudio.rt60(ir, 16000))
T30 0.60 s (EDT 0.55 s) · C50 +11.7 dB · DRR +6.0 dB · fit 1.000
Removing it is a different problem and is not here. Spectral subtraction has nothing to subtract, and the single-channel predictive methods did not survive measurement. What this module is for is making controlled reverberation, for augmentation, and measuring it when you have an impulse response.
Everything here is mono: a (frames, channels) array is refused rather than
read as one interleaved signal twice as long.
The room here is a statistical one, not a real one. Exponentially decaying
noise is the textbook model of a diffuse tail and it is what an augmentation
pipeline wants: one knob, reproducible, and the answer known in advance. It has
no early-reflection pattern, so it will not stand in for a measured impulse
response when the shape of the early energy is what matters. Feed
apply_rir a real one when you have it.
Every measurement says how much to believe it. A decay curve that is not
straight -- too short a tail, noise reaching the floor, a recording that was
never impulsive -- produces a T30 anyway. Reverberation.fit is the R²
of the line that was fitted, and it is the difference between "this room rings
for 600 ms" and "this arithmetic ran".
Classes:
| Name | Description |
|---|---|
Reverberation |
What a room does, measured from its impulse response. |
Functions:
| Name | Description |
|---|---|
rir |
A synthetic room impulse response: exponentially decaying noise. |
apply_rir |
Put y in the room described by ir. |
schroeder_curve |
Backward-integrated energy decay of ir, in dB, starting at 0. |
rt60 |
Measure a room from its impulse response, to ISO 3382-1. |
Attributes:
| Name | Type | Description |
|---|---|---|
MAX_EDT_RATIO |
How far EDT and T30 may disagree before the measurement stops counting as |
MAX_EDT_RATIO
module-attribute
¶
How far EDT and T30 may disagree before the measurement stops counting as
one. A sanity bound, not a figure from the standard -- see
Reverberation.reliable.
Reverberation
dataclass
¶
Reverberation(
t20: float,
t30: float,
edt: float,
c50: float,
c80: float,
drr: float,
fit: float,
sr: int,
)
What a room does, measured from its impulse response.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
t20
|
float
|
reverberation time from the -5 to -25 dB decay, in seconds. |
required |
t30
|
float
|
the same from -5 to -35 dB. The usual figure quoted as "the
RT60", and the one |
required |
edt
|
float
|
early decay time -- the first 10 dB, extrapolated. Closer to what a listener calls liveness, and it disagrees with T30 in a room that is not diffuse. |
required |
c50
|
float
|
clarity for speech, in dB: energy in the first 50 ms against everything after it. Positive means the direct sound wins. |
required |
c80
|
float
|
the same split at 80 ms, the figure used for music. |
required |
drr
|
float
|
direct-to-reverberant ratio in dB, taking +/-2.5 ms around the peak as the direct sound. |
required |
fit
|
float
|
R² of the straight line fitted to the T30 decay. Read this before quoting the rest. |
required |
Methods:
| Name | Description |
|---|---|
reliable |
Was this an impulse response, and was its decay straight? |
Attributes:
| Name | Type | Description |
|---|---|---|
seconds |
float
|
The reverberation time, in seconds -- T30 by convention. |
reliable
¶
reliable(
minimum: float = 0.9, edt_ratio: float = MAX_EDT_RATIO
) -> bool
Was this an impulse response, and was its decay straight?
Two questions, because the R² alone answers only the second. Backward integration makes any signal decay monotonically, and over a long clip that decay can be quite straight -- this repo's speech fixture fits at R² 0.94 and comes back with a 3.9 second "reverberation time".
What gives it away is that its EDT and T30 disagree by a factor of
eleven. In a real room the first 10 dB and the 5-to-35 dB span describe
the same tail and land within a few per cent of each other, so a large
disagreement means the two spans were measuring different things and
neither of them was a room. edt_ratio is a sanity bound and not a
figure from the standard: over 40 seeds of rir(16000, 0.6) the
ratio runs 0.87 to 1.16 at drr_db=6 and 0.95 to 1.09 at 0, and
published halls stay well inside 0.8 to 1.3.
A close microphone has no EDT to compare: the direct sound alone
carries the first 10 dB of the decay, so the EDT is nan. That says
nothing against the tail, and the decay is then judged on its fit
alone.
Source code in kudio/core/room.py
rir
¶
rir(
sr: int,
rt60: float = 0.5,
*,
drr_db: float = 0.0,
seconds: Optional[float] = None,
seed: Optional[int] = None,
) -> ndarray
A synthetic room impulse response: exponentially decaying noise.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rt60
|
float
|
the tail this room is asked for, in seconds -- the time the envelope takes to fall 60 dB. |
0.5
|
drr_db
|
float
|
how far the direct path sits above the reverberant energy,
in dB. This is distance in a readable unit: a close microphone is
+10 dB or more, a metre or two away is around 0, and the far end of a
hall is negative. Solved for exactly, so |
0.0
|
seconds
|
Optional[float]
|
how long to make it. Defaults to 1.5x rt60, which holds the decay well past the 35 dB that T30 needs. |
None
|
seed
|
Optional[int]
|
makes the room reproducible, the way
|
None
|
Source code in kudio/core/room.py
apply_rir
¶
Put y in the room described by ir.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
trim
|
bool
|
return the same number of samples that went in. The convolution is longer by the length of the tail, and a clip that grows every time it is processed stops lining up with everything measured against it. |
True
|
normalise
|
bool
|
keep the output's peak where the input's was. A room with a loud direct path has a gain, and an augmentation pipeline that also changes the level is measuring two things at once. |
True
|
Source code in kudio/core/room.py
schroeder_curve
¶
Backward-integrated energy decay of ir, in dB, starting at 0.
Schroeder's method: the decay a room would show if you averaged infinitely many noise bursts, obtained from one impulse by integrating its energy from the end backwards. It is what makes a single measurement readable.
Source code in kudio/core/room.py
rt60
¶
rt60(ir: ndarray, sr: int) -> Reverberation
Measure a room from its impulse response, to ISO 3382-1.
T20, T30 and EDT are read off one backward-integrated decay curve
(schroeder_curve); C50, C80 and DRR are energy ratios taken from
the response around its peak. The response is measured from its onset,
the first sample within 20 dB of the peak, so leading silence -- time of
flight, converter latency -- is not counted as decay.
It does not find or remove a noise floor. A recorded response whose tail
reaches the floor less than 35 dB down has no honest T30, and
Reverberation.reliable cannot tell: check the raw response first.
Source code in kudio/core/room.py
kudio.effects.channel
¶
What the transmission did to it: companding, quantisation, packet loss.
Speech arrives degraded in three ways. Noise is additive --
kudio.Synthesizer mixes it at a chosen SNR. A room is
convolutive -- kudio.apply_rir smears the signal across time. The
third is the channel: a codec quantises, a link drops packets, a converter
throws bits away. The telephone band is a filter, but the codec and the lost
packets cannot be written as y + n or y * h, and they are what this
module adds.
>>> phone = kudio.telephone(speech, sr) # 300-3400 Hz, 8 kHz, G.711
>>> lossy = kudio.dropouts(speech, sr, loss=0.05, seed=0)
>>> rough = kudio.bit_depth(speech, bits=8)
This is the augmentation that matters for anything that will meet a telephone line or a conferencing codec, and it is well established for exactly that (Audio Codec Simulation based Data Augmentation for Telephony Speech Recognition, APSIPA 2019; packet-loss augmentation for contact-centre ASR, Applied Sciences 2022).
Companding is the interesting one. mu_law spends its 8 bits
logarithmically, so the quantisation noise follows the signal down instead of
sitting at a fixed floor: on this repo's fixture it holds ~37 dB of SNR over a
24 dB range of levels, where linear 8-bit loses the textbook 6 dB per halving.
That is the whole reason telephony uses it, and it is what the tests check.
These are models, not bit-exact codecs. mu_law and a_law
implement the G.711 companding curves quantised to 8 bits, not the
standard's piecewise-linear segment layout. Measured against Python's own
audioop -- the reference, and removed in 3.13, which is why it cannot be a
dependency -- the round-trip SNR comes out within about 0.9 dB of it over a
24 dB range of levels, and single samples within a few hundredths of full
scale. For augmentation that is the same thing; for a bit-exact decoder it is
not, and this does not claim to be one.
Functions:
| Name | Description |
|---|---|
independent_burst |
The mean run length that independent loss at rate loss produces. |
mu_law |
Push y through the G.711 mu-law companding curve and back. |
a_law |
The same, through the A-law curve -- G.711 as Europe specifies it. |
bit_depth |
Requantise y to bits, linearly. |
dropouts |
Drop packets, the way a bad link does. |
telephone |
Everything a phone line does, in the order it does it. |
channel_spec |
Normalise spec -- a preset name, a dict, or |
channel_tag |
A short, stable description of spec, for a manifest or a filename. |
apply_channel |
Run y through a whole link in the order a link does it. |
Attributes:
| Name | Type | Description |
|---|---|---|
MU_LAW_MU |
G.711's mu for the North American / Japanese law. |
|
A_LAW_A |
G.711's A for the European law. |
|
PACKET_MS |
Default packet length, in milliseconds. 20 ms is what G.711 over RTP uses, |
|
TELEPHONE_BAND |
The passband a telephone line gives you, in Hz, and the rate it runs at. |
|
CHANNEL_DEFAULTS |
What a channel can be asked for, and what it defaults to. One dictionary, |
|
CHANNEL_PRESETS |
The named links, so the common cases are one word. |
PACKET_MS
module-attribute
¶
Default packet length, in milliseconds. 20 ms is what G.711 over RTP uses, so a lost packet is 20 ms of nothing.
TELEPHONE_BAND
module-attribute
¶
The passband a telephone line gives you, in Hz, and the rate it runs at.
CHANNEL_DEFAULTS
module-attribute
¶
CHANNEL_DEFAULTS = {
"band": False,
"codec": "none",
"bits": None,
"loss": 0.0,
"burst": None,
"packet_ms": PACKET_MS,
"conceal": "silence",
}
What a channel can be asked for, and what it defaults to. One dictionary, so a corpus builder, the command line and a GUI all describe a link the same way instead of each inventing an argument order.
CHANNEL_PRESETS
module-attribute
¶
CHANNEL_PRESETS = {
"none": {},
"telephone": {"band": True, "codec": "mu_law"},
"telephone_a": {"band": True, "codec": "a_law"},
"mu_law": {"codec": "mu_law"},
"a_law": {"codec": "a_law"},
"voip": {
"codec": "mu_law",
"loss": 0.05,
"burst": 4.0,
"conceal": "hold",
},
}
The named links, so the common cases are one word.
independent_burst
¶
The mean run length that independent loss at rate loss produces.
Worth a function because the obvious guess is 1 and it is wrong.
Independent draws do land side by side sometimes, so the runs already
average 1 / (1 - loss) -- 1.05 packets at 5% loss, 1.25 at 20%.
Asking dropouts for burst=1 is therefore asking for something
less clustered than the default, not for the default.
Source code in kudio/effects/channel.py
mu_law
¶
Push y through the G.711 mu-law companding curve and back.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
bits
|
int
|
code word size. 8 is G.711; fewer is a cruder link and a way to ask how far a model's robustness reaches. The point of companding is that the quantisation noise tracks the signal:
loud passages get coarse steps and quiet ones get fine steps, so the
signal-to-noise ratio stays roughly constant instead of collapsing on
anything quiet. |
8
|
Source code in kudio/effects/channel.py
a_law
¶
The same, through the A-law curve -- G.711 as Europe specifies it.
A-law is slightly finer near silence and slightly coarser at the top than mu-law. The difference is audible only on very quiet material; it is here because a corpus recorded off a European line went through this one.
Source code in kudio/effects/channel.py
bit_depth
¶
bit_depth(
y: ndarray,
bits: int = 8,
*,
dither: bool = False,
seed: Optional[int] = None,
) -> ndarray
Requantise y to bits, linearly.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dither
|
bool
|
add triangular (TPDF) dither two steps wide, peak to peak, before rounding. It raises the noise floor and makes the error's mean and power independent of the signal, which is what stops quiet passages sounding granular rather than merely noisy. Pass seed with it, or every call dithers differently. A full-scale sine comes back at about |
False
|
Source code in kudio/effects/channel.py
dropouts
¶
dropouts(
y: ndarray,
sr: int,
*,
loss: float = 0.05,
packet_ms: float = PACKET_MS,
conceal: str = "silence",
burst: Optional[float] = None,
seed: Optional[int] = None,
return_spans: bool = False,
) -> Union[
ndarray, Tuple[ndarray, List[Tuple[float, float]]]
]
Drop packets, the way a bad link does.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
loss
|
float
|
fraction of packets to lose. Achieved exactly -- |
0.05
|
packet_ms
|
float
|
packet length. 20 ms is G.711 over RTP. |
PACKET_MS
|
conceal
|
str
|
|
'silence'
|
burst
|
Optional[float]
|
mean run length, in packets. |
None
|
seed
|
Optional[int]
|
which packets go, reproducibly. |
None
|
return_spans
|
bool
|
also return the lost packets as
|
False
|
Source code in kudio/effects/channel.py
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 | |
telephone
¶
telephone(
y: ndarray,
sr: int,
*,
codec: str = "mu_law",
band: Tuple[float, float] = TELEPHONE_BAND,
) -> ndarray
Everything a phone line does, in the order it does it.
Band-limit to 300-3400 Hz, run at 8 kHz, compand to 8 bits, come back. The result is returned at the rate it went in and the same length, so it drops straight into a dataset beside its own source.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
codec
|
str
|
The bandwidth is the part that survives being measured:
|
'mu_law'
|
Source code in kudio/effects/channel.py
channel_spec
¶
Normalise spec -- a preset name, a dict, or None -- to a dict.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spec
|
Optional[Union[str, dict]]
|
Returns |
required |
Source code in kudio/effects/channel.py
channel_tag
¶
A short, stable description of spec, for a manifest or a filename.
Readable rather than exhaustive: it is what went on the corpus label, and a reader should be able to tell two runs apart at a glance.
>>> kudio.channel_tag('telephone')
'telephone'
>>> kudio.channel_tag({'codec': 'mu_law', 'loss': 0.05, 'burst': 4})
'mu_law+loss5%x4'
Source code in kudio/effects/channel.py
apply_channel
¶
apply_channel(
y: ndarray,
sr: int,
spec: Optional[Union[str, dict]],
seed: Optional[int] = None,
) -> ndarray
Run y through a whole link in the order a link does it.
Band and rate first, then the codec, then the converter, then the wire -- packets are lost from the encoded signal, not from the microphone, so the loss goes last. Same length and same rate as it went in.
>>> kudio.apply_channel(mix, sr, 'telephone')
>>> kudio.apply_channel(mix, sr, {'preset': 'voip', 'loss': 0.1}, seed=7)
...