Kudio 3.0.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.
- kudio/__init__.py +48 -0
- kudio/_deprecation.py +29 -0
- kudio/_version.py +4 -0
- kudio/cli.py +91 -0
- kudio/config.py +244 -0
- kudio/core/__init__.py +91 -0
- kudio/core/buffer.py +135 -0
- kudio/core/evaluator.py +263 -0
- kudio/core/feature.py +282 -0
- kudio/core/io.py +238 -0
- kudio/core/manager.py +176 -0
- kudio/core/stream.py +474 -0
- kudio/core/synth.py +256 -0
- kudio/effects/__init__.py +20 -0
- kudio/effects/augment.py +104 -0
- kudio/effects/silence.py +35 -0
- kudio/enhance/__init__.py +5 -0
- kudio/enhance/traditional.py +244 -0
- kudio/exceptions.py +55 -0
- kudio/py.typed +0 -0
- kudio/util/__init__.py +47 -0
- kudio/util/check.py +194 -0
- kudio/util/colors.py +145 -0
- kudio/util/conv.py +75 -0
- kudio/util/parallel.py +26 -0
- kudio/util/tools.py +65 -0
- kudio/util/visual.py +333 -0
- kudio-3.0.0.dist-info/METADATA +179 -0
- kudio-3.0.0.dist-info/RECORD +33 -0
- kudio-3.0.0.dist-info/WHEEL +5 -0
- kudio-3.0.0.dist-info/entry_points.txt +2 -0
- kudio-3.0.0.dist-info/licenses/LICENSE.txt +21 -0
- kudio-3.0.0.dist-info/top_level.txt +1 -0
kudio/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""kudio — a lightweight, composable audio toolkit for acoustic inspection.
|
|
3
|
+
|
|
4
|
+
Audio I/O, streaming/recording, feature extraction, noisy-data synthesis,
|
|
5
|
+
effects/augmentation, enhancement, and evaluation metrics.
|
|
6
|
+
|
|
7
|
+
>>> import kudio
|
|
8
|
+
>>> y, sr = kudio.file_load('some.wav')
|
|
9
|
+
>>> spec = kudio.waveform_to_spectrogram(y)
|
|
10
|
+
>>> clean = kudio.trim_silence(y)[0]
|
|
11
|
+
|
|
12
|
+
Heavy/niche features are optional extras and are *not* imported here:
|
|
13
|
+
plotting (`kudio.util.visual`, needs ``kudio[viz]``), Excel export
|
|
14
|
+
(`kudio.util.conv`, needs ``kudio[data]``), and live audio
|
|
15
|
+
(`kudio[audio]`).
|
|
16
|
+
"""
|
|
17
|
+
import logging as _logging
|
|
18
|
+
|
|
19
|
+
from kudio._version import __author__, __version__
|
|
20
|
+
from kudio.config import KC, KudioConfig
|
|
21
|
+
from kudio.exceptions import (
|
|
22
|
+
AudioIOError,
|
|
23
|
+
DependencyError,
|
|
24
|
+
DeviceError,
|
|
25
|
+
FeatureError,
|
|
26
|
+
KudioError,
|
|
27
|
+
SynthesisError,
|
|
28
|
+
UnsupportedFormatError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from kudio import core, effects, enhance, util # noqa: E402
|
|
32
|
+
from kudio.core import * # noqa: F401,F403 (curated __all__)
|
|
33
|
+
from kudio.effects import * # noqa: F401,F403
|
|
34
|
+
from kudio.enhance import trad_enhance, wavelet_low_pass_filter
|
|
35
|
+
from kudio.util import * # noqa: F401,F403 (curated __all__)
|
|
36
|
+
|
|
37
|
+
_logging.getLogger(__name__).addHandler(_logging.NullHandler())
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
'__version__', '__author__', 'KudioConfig', 'KC',
|
|
41
|
+
'trad_enhance', 'wavelet_low_pass_filter',
|
|
42
|
+
# exceptions
|
|
43
|
+
'KudioError', 'AudioIOError', 'UnsupportedFormatError', 'DeviceError',
|
|
44
|
+
'FeatureError', 'SynthesisError', 'DependencyError',
|
|
45
|
+
]
|
|
46
|
+
__all__ += core.__all__
|
|
47
|
+
__all__ += effects.__all__
|
|
48
|
+
__all__ += util.__all__
|
kudio/_deprecation.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Helpers for keeping backwards-compatible aliases while nudging callers
|
|
3
|
+
towards the v3 canonical names."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import functools
|
|
7
|
+
import warnings
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def alias(new: Callable, name: Optional[str] = None) -> Callable:
|
|
12
|
+
"""Return a wrapper around *new* that emits a ``DeprecationWarning``.
|
|
13
|
+
|
|
14
|
+
>>> w2s = alias(waveform_to_spectrogram, 'w2s')
|
|
15
|
+
"""
|
|
16
|
+
old_name = name or new.__name__
|
|
17
|
+
|
|
18
|
+
@functools.wraps(new)
|
|
19
|
+
def wrapper(*args, **kwargs):
|
|
20
|
+
warnings.warn(
|
|
21
|
+
f"kudio.{old_name}() is deprecated and will be removed in a future "
|
|
22
|
+
f"release; use kudio.{new.__name__}() instead.",
|
|
23
|
+
DeprecationWarning, stacklevel=2)
|
|
24
|
+
return new(*args, **kwargs)
|
|
25
|
+
|
|
26
|
+
wrapper.__name__ = old_name
|
|
27
|
+
wrapper.__qualname__ = old_name
|
|
28
|
+
wrapper.__doc__ = f"Deprecated alias for :func:`{new.__name__}`."
|
|
29
|
+
return wrapper
|
kudio/_version.py
ADDED
kudio/cli.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""``kudio`` command line: quick audio utilities.
|
|
3
|
+
|
|
4
|
+
kudio devices # list input/output audio devices
|
|
5
|
+
kudio info file.wav # duration / sr / channels / peak
|
|
6
|
+
kudio convert in.wav out.wav --rate 16000 --subtype PCM_16
|
|
7
|
+
kudio synth --clean C --noise N --out O --snr -5 0 5
|
|
8
|
+
kudio trim in.wav out.wav --top-db 30
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional, Sequence
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger("kudio.cli")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
22
|
+
p = argparse.ArgumentParser(prog="kudio", description="kudio audio utilities")
|
|
23
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
24
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
25
|
+
|
|
26
|
+
sub.add_parser("devices", help="list audio devices (needs kudio[audio])")
|
|
27
|
+
|
|
28
|
+
s_info = sub.add_parser("info", help="show audio file properties")
|
|
29
|
+
s_info.add_argument("file")
|
|
30
|
+
|
|
31
|
+
s_conv = sub.add_parser("convert", help="resample / re-encode a wave file")
|
|
32
|
+
s_conv.add_argument("src")
|
|
33
|
+
s_conv.add_argument("dst")
|
|
34
|
+
s_conv.add_argument("--rate", type=int, default=None)
|
|
35
|
+
s_conv.add_argument("--subtype", default="PCM_16")
|
|
36
|
+
|
|
37
|
+
s_syn = sub.add_parser("synth", help="mix clean + noise at given SNRs")
|
|
38
|
+
s_syn.add_argument("--clean", required=True)
|
|
39
|
+
s_syn.add_argument("--noise", required=True)
|
|
40
|
+
s_syn.add_argument("--out", required=True)
|
|
41
|
+
s_syn.add_argument("--snr", type=int, nargs="+", default=[-5, 0, 5])
|
|
42
|
+
s_syn.add_argument("--mode", default="inc", choices=["inc", "reg"])
|
|
43
|
+
s_syn.add_argument("--seed", type=int, default=None)
|
|
44
|
+
|
|
45
|
+
s_trim = sub.add_parser("trim", help="trim leading/trailing silence")
|
|
46
|
+
s_trim.add_argument("src")
|
|
47
|
+
s_trim.add_argument("dst")
|
|
48
|
+
s_trim.add_argument("--top-db", type=float, default=30.0)
|
|
49
|
+
return p
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
53
|
+
args = build_parser().parse_args(argv)
|
|
54
|
+
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO,
|
|
55
|
+
format="%(levelname)s %(name)s: %(message)s")
|
|
56
|
+
import kudio
|
|
57
|
+
|
|
58
|
+
if args.command == "devices":
|
|
59
|
+
with kudio.CheckDevice() as cd:
|
|
60
|
+
for d in cd.system_devices():
|
|
61
|
+
print(f"[{d['index']:>2}] in={d['in']} out={d['out']} "
|
|
62
|
+
f"fs={int(d['fs'])} {d['Device']}")
|
|
63
|
+
elif args.command == "info":
|
|
64
|
+
y, sr = kudio.file_load(args.file, sr=None, mono=False)
|
|
65
|
+
ch = 1 if y.ndim == 1 else y.shape[1]
|
|
66
|
+
n = y.shape[0]
|
|
67
|
+
import numpy as np
|
|
68
|
+
print(f"file : {args.file}")
|
|
69
|
+
print(f"samplerate: {sr} Hz")
|
|
70
|
+
print(f"channels : {ch}")
|
|
71
|
+
print(f"duration : {n / sr:.3f} s ({n} samples)")
|
|
72
|
+
print(f"peak : {float(np.max(np.abs(y))):.4f}")
|
|
73
|
+
elif args.command == "convert":
|
|
74
|
+
y, sr = kudio.file_load(args.src, sr=args.rate, mono=True)
|
|
75
|
+
kudio.save_wave(args.dst, y, sr, subtype=args.subtype)
|
|
76
|
+
print(f"wrote {args.dst} ({sr} Hz, {args.subtype})")
|
|
77
|
+
elif args.command == "synth":
|
|
78
|
+
syx = kudio.Synthesizer(args.clean, args.noise, out_path=args.out,
|
|
79
|
+
snr_ratio=args.snr)
|
|
80
|
+
result = syx.syn(mode=args.mode, seed=args.seed)
|
|
81
|
+
print(f"synthesized {len(result or [])} files -> {args.out}")
|
|
82
|
+
elif args.command == "trim":
|
|
83
|
+
y, sr = kudio.file_load(args.src, sr=None, mono=True)
|
|
84
|
+
trimmed, (a, b) = kudio.trim_silence(y, top_db=args.top_db)
|
|
85
|
+
kudio.save_wave(args.dst, trimmed, sr)
|
|
86
|
+
print(f"trimmed [{a}:{b}] -> {args.dst} ({len(trimmed)} samples)")
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
sys.exit(main())
|
kudio/config.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""YAML-backed configuration with attribute access.
|
|
3
|
+
|
|
4
|
+
>>> from kudio import KudioConfig
|
|
5
|
+
>>> cfg = KudioConfig.load_config('TrainConfig.yaml')
|
|
6
|
+
>>> cfg.train.clean
|
|
7
|
+
'data/train/clean'
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
__all__ = ['KudioConfig', 'KC']
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Dict(dict):
|
|
24
|
+
"""dict subclass with attribute access and recursive conversion.
|
|
25
|
+
|
|
26
|
+
Vendored from the `addict <https://github.com/mewwts/addict>`_ project
|
|
27
|
+
(MIT license), trimmed to what kudio needs.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(__self, *args, **kwargs):
|
|
31
|
+
object.__setattr__(__self, '__parent', kwargs.pop('__parent', None))
|
|
32
|
+
object.__setattr__(__self, '__key', kwargs.pop('__key', None))
|
|
33
|
+
object.__setattr__(__self, '__frozen', False)
|
|
34
|
+
for arg in args:
|
|
35
|
+
if not arg:
|
|
36
|
+
continue
|
|
37
|
+
if isinstance(arg, dict):
|
|
38
|
+
for key, val in arg.items():
|
|
39
|
+
__self[key] = __self._hook(val)
|
|
40
|
+
elif isinstance(arg, tuple) and (not isinstance(arg[0], tuple)):
|
|
41
|
+
__self[arg[0]] = __self._hook(arg[1])
|
|
42
|
+
else:
|
|
43
|
+
for key, val in iter(arg):
|
|
44
|
+
__self[key] = __self._hook(val)
|
|
45
|
+
|
|
46
|
+
for key, val in kwargs.items():
|
|
47
|
+
__self[key] = __self._hook(val)
|
|
48
|
+
|
|
49
|
+
def __setattr__(self, name, value):
|
|
50
|
+
if hasattr(self.__class__, name):
|
|
51
|
+
raise AttributeError(
|
|
52
|
+
f"'Dict' object attribute {name!r} is read-only")
|
|
53
|
+
self[name] = value
|
|
54
|
+
|
|
55
|
+
def __setitem__(self, name, value):
|
|
56
|
+
is_frozen = (hasattr(self, '__frozen') and
|
|
57
|
+
object.__getattribute__(self, '__frozen'))
|
|
58
|
+
if is_frozen and name not in super().keys():
|
|
59
|
+
raise KeyError(name)
|
|
60
|
+
super().__setitem__(name, value)
|
|
61
|
+
try:
|
|
62
|
+
p = object.__getattribute__(self, '__parent')
|
|
63
|
+
key = object.__getattribute__(self, '__key')
|
|
64
|
+
except AttributeError:
|
|
65
|
+
p = None
|
|
66
|
+
key = None
|
|
67
|
+
if p is not None:
|
|
68
|
+
p[key] = self
|
|
69
|
+
object.__delattr__(self, '__parent')
|
|
70
|
+
object.__delattr__(self, '__key')
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def _hook(cls, item):
|
|
74
|
+
if isinstance(item, dict):
|
|
75
|
+
return cls(item)
|
|
76
|
+
if isinstance(item, (list, tuple)):
|
|
77
|
+
return type(item)(cls._hook(elem) for elem in item)
|
|
78
|
+
return item
|
|
79
|
+
|
|
80
|
+
def __getattr__(self, item):
|
|
81
|
+
return self.__getitem__(item)
|
|
82
|
+
|
|
83
|
+
def __missing__(self, name):
|
|
84
|
+
if object.__getattribute__(self, '__frozen'):
|
|
85
|
+
raise KeyError(name)
|
|
86
|
+
return self.__class__(__parent=self, __key=name)
|
|
87
|
+
|
|
88
|
+
def __delattr__(self, name):
|
|
89
|
+
del self[name]
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> dict:
|
|
92
|
+
base = {}
|
|
93
|
+
for key, value in self.items():
|
|
94
|
+
if isinstance(value, type(self)):
|
|
95
|
+
base[key] = value.to_dict()
|
|
96
|
+
elif isinstance(value, (list, tuple)):
|
|
97
|
+
base[key] = type(value)(
|
|
98
|
+
item.to_dict() if isinstance(item, type(self)) else item
|
|
99
|
+
for item in value)
|
|
100
|
+
else:
|
|
101
|
+
base[key] = value
|
|
102
|
+
return base
|
|
103
|
+
|
|
104
|
+
def copy(self):
|
|
105
|
+
return copy.copy(self)
|
|
106
|
+
|
|
107
|
+
def deepcopy(self):
|
|
108
|
+
return copy.deepcopy(self)
|
|
109
|
+
|
|
110
|
+
def __deepcopy__(self, memo):
|
|
111
|
+
other = self.__class__()
|
|
112
|
+
memo[id(self)] = other
|
|
113
|
+
for key, value in self.items():
|
|
114
|
+
other[copy.deepcopy(key, memo)] = copy.deepcopy(value, memo)
|
|
115
|
+
return other
|
|
116
|
+
|
|
117
|
+
def update(self, *args, **kwargs):
|
|
118
|
+
other = {}
|
|
119
|
+
if args:
|
|
120
|
+
if len(args) > 1:
|
|
121
|
+
raise TypeError("update expected at most 1 argument")
|
|
122
|
+
other.update(args[0])
|
|
123
|
+
other.update(kwargs)
|
|
124
|
+
for k, v in other.items():
|
|
125
|
+
if ((k not in self) or
|
|
126
|
+
(not isinstance(self[k], dict)) or
|
|
127
|
+
(not isinstance(v, dict))):
|
|
128
|
+
self[k] = v
|
|
129
|
+
else:
|
|
130
|
+
self[k].update(v)
|
|
131
|
+
|
|
132
|
+
def __getnewargs__(self):
|
|
133
|
+
return tuple(self.items())
|
|
134
|
+
|
|
135
|
+
def __getstate__(self):
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def __setstate__(self, state):
|
|
139
|
+
self.update(state)
|
|
140
|
+
|
|
141
|
+
def __or__(self, other):
|
|
142
|
+
if not isinstance(other, (Dict, dict)):
|
|
143
|
+
return NotImplemented
|
|
144
|
+
new = Dict(self)
|
|
145
|
+
new.update(other)
|
|
146
|
+
return new
|
|
147
|
+
|
|
148
|
+
def __ror__(self, other):
|
|
149
|
+
if not isinstance(other, (Dict, dict)):
|
|
150
|
+
return NotImplemented
|
|
151
|
+
new = Dict(other)
|
|
152
|
+
new.update(self)
|
|
153
|
+
return new
|
|
154
|
+
|
|
155
|
+
def __ior__(self, other):
|
|
156
|
+
self.update(other)
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def setdefault(self, key, default=None):
|
|
160
|
+
if key in self:
|
|
161
|
+
return self[key]
|
|
162
|
+
self[key] = default
|
|
163
|
+
return default
|
|
164
|
+
|
|
165
|
+
def freeze(self, should_freeze: bool = True):
|
|
166
|
+
object.__setattr__(self, '__frozen', should_freeze)
|
|
167
|
+
for val in self.values():
|
|
168
|
+
if isinstance(val, Dict):
|
|
169
|
+
val.freeze(should_freeze)
|
|
170
|
+
|
|
171
|
+
def unfreeze(self):
|
|
172
|
+
self.freeze(False)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class KudioConfig(Dict):
|
|
176
|
+
"""Attribute-style config with YAML load/save and audio-centric defaults."""
|
|
177
|
+
|
|
178
|
+
def write_config(self, file: str = 'cfg.yaml', exist_ok: bool = False) -> bool:
|
|
179
|
+
"""Dump the config to *file*; refuses to overwrite unless ``exist_ok``."""
|
|
180
|
+
if Path(file).exists() and not exist_ok:
|
|
181
|
+
log.warning("Config exists, not overwritten: %s", file)
|
|
182
|
+
return False
|
|
183
|
+
with open(str(file), 'w', encoding='utf-8') as f:
|
|
184
|
+
yaml.safe_dump(self.to_dict(), f, allow_unicode=True)
|
|
185
|
+
log.info("Config written to %s", file)
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def load_config(cls, cfg_dir: str = '') -> Optional['KudioConfig']:
|
|
190
|
+
"""Load a YAML config file.
|
|
191
|
+
|
|
192
|
+
*cfg_dir* may be a file, or a directory containing exactly one
|
|
193
|
+
``cfg.yaml``. Anything else falls back to :meth:`default_config`.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def _load(f_: Path):
|
|
197
|
+
log.info("Load config -> %s", f_.absolute())
|
|
198
|
+
with open(str(f_.absolute()), encoding="utf-8") as f:
|
|
199
|
+
return yaml.safe_load(f)
|
|
200
|
+
|
|
201
|
+
_file = Path(cfg_dir)
|
|
202
|
+
if _file.is_file():
|
|
203
|
+
return cls(_load(_file))
|
|
204
|
+
if _file.is_dir():
|
|
205
|
+
candidates = list(_file.rglob("cfg.yaml"))
|
|
206
|
+
if len(candidates) == 1:
|
|
207
|
+
return cls(_load(candidates[0]))
|
|
208
|
+
log.info("No config file found, using defaults")
|
|
209
|
+
return cls(**cls.default_config())
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def default_config() -> dict:
|
|
213
|
+
"""Audio-only defaults. Training/model settings live in the
|
|
214
|
+
application layer (e.g. chptrain), not in the audio library."""
|
|
215
|
+
return {
|
|
216
|
+
'waveform': {
|
|
217
|
+
'type': '.wav',
|
|
218
|
+
'default_rate': 16000,
|
|
219
|
+
'common_rate': [192000, 96000, 88200, 64000, 48000, 44100,
|
|
220
|
+
32000, 22050, 16000, 11025, 8000, 6000],
|
|
221
|
+
'resample_rate': 16000,
|
|
222
|
+
'channels': 'mono',
|
|
223
|
+
'format': '16bit'},
|
|
224
|
+
'device': {
|
|
225
|
+
'pool_max': 200, 'cpu_cores': 8},
|
|
226
|
+
'f_mfe': {
|
|
227
|
+
'n_mels': 40,
|
|
228
|
+
'bank': 1,
|
|
229
|
+
'f0': 0,
|
|
230
|
+
'f1': 48000},
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
def show(self, key: Optional[str] = None) -> None:
|
|
234
|
+
"""Print one key (or the whole config) to the log."""
|
|
235
|
+
if key is None:
|
|
236
|
+
for k, value in self.items():
|
|
237
|
+
log.info("%s: %s", k, value)
|
|
238
|
+
elif key in self:
|
|
239
|
+
log.info("%s: %s", key, self.get(key))
|
|
240
|
+
else:
|
|
241
|
+
log.warning("Unknown config key: %s", key)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
KC = KudioConfig
|
kudio/core/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Core audio functionality: I/O, buffering, streaming, features, synthesis,
|
|
3
|
+
and evaluation."""
|
|
4
|
+
from kudio.core.buffer import AudioBuffer, FetchBuffer
|
|
5
|
+
from kudio.core.evaluator import (
|
|
6
|
+
AudioEvaluate,
|
|
7
|
+
eval_metrics,
|
|
8
|
+
segmental_snr,
|
|
9
|
+
si_sdr,
|
|
10
|
+
snr,
|
|
11
|
+
)
|
|
12
|
+
from kudio.core.feature import (
|
|
13
|
+
# canonical (v3)
|
|
14
|
+
denoise_spec2wav,
|
|
15
|
+
denoise_wav2spec,
|
|
16
|
+
features2matrix,
|
|
17
|
+
file_to_spectrogram,
|
|
18
|
+
logspec_from_files,
|
|
19
|
+
melspectrogram,
|
|
20
|
+
mfcc,
|
|
21
|
+
mfcc_from_files,
|
|
22
|
+
save_spectrogram_as_wave,
|
|
23
|
+
spectrogram_to_waveform,
|
|
24
|
+
waveform_to_spectrogram,
|
|
25
|
+
wave_separate,
|
|
26
|
+
wave_slicing,
|
|
27
|
+
# deprecated aliases
|
|
28
|
+
concat_logspec,
|
|
29
|
+
concat_logspec_,
|
|
30
|
+
concat_mfcc,
|
|
31
|
+
concat_mfcc_,
|
|
32
|
+
contextual_LogSpectrogram,
|
|
33
|
+
f2s,
|
|
34
|
+
s2w,
|
|
35
|
+
spec2wav,
|
|
36
|
+
spec2wavform,
|
|
37
|
+
w2mfcc,
|
|
38
|
+
w2s,
|
|
39
|
+
wav2mel,
|
|
40
|
+
wav2mfcc,
|
|
41
|
+
wav2spec,
|
|
42
|
+
wavform2spec,
|
|
43
|
+
)
|
|
44
|
+
from kudio.core.io import (
|
|
45
|
+
LoadAudio,
|
|
46
|
+
check_file,
|
|
47
|
+
check_input,
|
|
48
|
+
check_path,
|
|
49
|
+
copy_waves,
|
|
50
|
+
file_load,
|
|
51
|
+
load_wave,
|
|
52
|
+
load_waves,
|
|
53
|
+
save_wave,
|
|
54
|
+
)
|
|
55
|
+
from kudio.core.manager import AudioDataManager
|
|
56
|
+
from kudio.core.stream import (
|
|
57
|
+
LocalStreamReader,
|
|
58
|
+
Recorder,
|
|
59
|
+
RemoteStreamReader,
|
|
60
|
+
play_audio,
|
|
61
|
+
record,
|
|
62
|
+
wave_decode,
|
|
63
|
+
wave_encode,
|
|
64
|
+
)
|
|
65
|
+
from kudio.core.synth import Synthesizer
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
# buffer
|
|
69
|
+
'AudioBuffer', 'FetchBuffer',
|
|
70
|
+
# evaluator + lightweight metrics
|
|
71
|
+
'AudioEvaluate', 'eval_metrics', 'si_sdr', 'snr', 'segmental_snr',
|
|
72
|
+
# feature (canonical)
|
|
73
|
+
'denoise_spec2wav', 'denoise_wav2spec', 'features2matrix',
|
|
74
|
+
'file_to_spectrogram', 'logspec_from_files', 'melspectrogram', 'mfcc',
|
|
75
|
+
'mfcc_from_files', 'save_spectrogram_as_wave', 'spectrogram_to_waveform',
|
|
76
|
+
'waveform_to_spectrogram', 'wave_separate', 'wave_slicing',
|
|
77
|
+
# feature (deprecated aliases)
|
|
78
|
+
'concat_logspec', 'concat_logspec_', 'concat_mfcc', 'concat_mfcc_',
|
|
79
|
+
'contextual_LogSpectrogram', 'f2s', 's2w', 'spec2wav', 'spec2wavform',
|
|
80
|
+
'w2mfcc', 'w2s', 'wav2mel', 'wav2mfcc', 'wav2spec', 'wavform2spec',
|
|
81
|
+
# io
|
|
82
|
+
'LoadAudio', 'check_file', 'check_input', 'check_path', 'copy_waves',
|
|
83
|
+
'file_load', 'load_wave', 'load_waves', 'save_wave',
|
|
84
|
+
# manager
|
|
85
|
+
'AudioDataManager',
|
|
86
|
+
# stream
|
|
87
|
+
'LocalStreamReader', 'Recorder', 'RemoteStreamReader', 'play_audio',
|
|
88
|
+
'record', 'wave_decode', 'wave_encode',
|
|
89
|
+
# synth
|
|
90
|
+
'Synthesizer',
|
|
91
|
+
]
|
kudio/core/buffer.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Thread-safe frame buffers shared between audio producers and consumers."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
__all__ = ['AudioBuffer', 'FetchBuffer']
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AudioBuffer:
|
|
18
|
+
"""Bounded FIFO of audio frames shared between producer/consumer threads.
|
|
19
|
+
|
|
20
|
+
A stream reader calls :meth:`add` for every incoming frame; consumers either
|
|
21
|
+
poll frames with :meth:`get` (e.g. for real-time plotting) or collect a fixed
|
|
22
|
+
number of consecutive frames with :meth:`get_fixed_size_data` (e.g. timed
|
|
23
|
+
recording). All methods are thread-safe.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, size: int = 2):
|
|
27
|
+
if size < 1:
|
|
28
|
+
raise ValueError(f"size must be >= 1, got {size}")
|
|
29
|
+
self.buffer_size = int(size)
|
|
30
|
+
self._frames: deque = deque()
|
|
31
|
+
self._cond = threading.Condition()
|
|
32
|
+
# fixed-size capture state (timed recording)
|
|
33
|
+
self._capture: Optional[List[np.ndarray]] = None
|
|
34
|
+
self._capture_left = 0
|
|
35
|
+
self._capture_done = threading.Event()
|
|
36
|
+
|
|
37
|
+
def add(self, data, drop_full: bool = True) -> None:
|
|
38
|
+
"""Append one frame.
|
|
39
|
+
|
|
40
|
+
With ``drop_full=True`` (default) the oldest frame is dropped when the
|
|
41
|
+
buffer is full; otherwise the call blocks until a slot is free.
|
|
42
|
+
"""
|
|
43
|
+
with self._cond:
|
|
44
|
+
# feed an active fixed-size capture first so timed recordings
|
|
45
|
+
# do not lose frames to a slow consumer
|
|
46
|
+
if self._capture is not None and self._capture_left > 0:
|
|
47
|
+
self._capture.append(data)
|
|
48
|
+
self._capture_left -= 1
|
|
49
|
+
if self._capture_left == 0:
|
|
50
|
+
self._capture_done.set()
|
|
51
|
+
if drop_full:
|
|
52
|
+
if len(self._frames) >= self.buffer_size:
|
|
53
|
+
self._frames.popleft()
|
|
54
|
+
else:
|
|
55
|
+
while len(self._frames) >= self.buffer_size:
|
|
56
|
+
self._cond.wait()
|
|
57
|
+
self._frames.append(data)
|
|
58
|
+
self._cond.notify_all()
|
|
59
|
+
|
|
60
|
+
def get(self, timeout: Optional[float] = None):
|
|
61
|
+
"""Pop the oldest frame, blocking until one is available.
|
|
62
|
+
|
|
63
|
+
Raises ``TimeoutError`` if *timeout* (seconds) elapses first.
|
|
64
|
+
"""
|
|
65
|
+
with self._cond:
|
|
66
|
+
while not self._frames:
|
|
67
|
+
if not self._cond.wait(timeout=timeout):
|
|
68
|
+
raise TimeoutError("AudioBuffer.get timed out")
|
|
69
|
+
data = self._frames.popleft()
|
|
70
|
+
self._cond.notify_all()
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
def get_fixed_size_data(self, queue_size: int) -> np.ndarray:
|
|
74
|
+
"""Block until *queue_size* frames have arrived, then return them
|
|
75
|
+
concatenated with ``np.hstack``."""
|
|
76
|
+
with self._cond:
|
|
77
|
+
self._capture = []
|
|
78
|
+
self._capture_left = int(queue_size)
|
|
79
|
+
self._capture_done.clear()
|
|
80
|
+
log.info("Buffer: capturing %d frames", queue_size)
|
|
81
|
+
self._capture_done.wait()
|
|
82
|
+
with self._cond:
|
|
83
|
+
frames, self._capture = self._capture, None
|
|
84
|
+
return np.hstack(frames)
|
|
85
|
+
|
|
86
|
+
def clear(self) -> bool:
|
|
87
|
+
"""Drop all buffered frames. Returns True if anything was dropped."""
|
|
88
|
+
with self._cond:
|
|
89
|
+
had_data = bool(self._frames)
|
|
90
|
+
self._frames.clear()
|
|
91
|
+
self._cond.notify_all()
|
|
92
|
+
if had_data:
|
|
93
|
+
log.info("Buffer: cleared")
|
|
94
|
+
return had_data
|
|
95
|
+
|
|
96
|
+
def size(self) -> int:
|
|
97
|
+
return len(self._frames)
|
|
98
|
+
|
|
99
|
+
def max_size(self) -> int:
|
|
100
|
+
return self.buffer_size
|
|
101
|
+
|
|
102
|
+
def is_full(self) -> bool:
|
|
103
|
+
return len(self._frames) >= self.buffer_size
|
|
104
|
+
|
|
105
|
+
def is_empty(self) -> bool:
|
|
106
|
+
return not self._frames
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class FetchBuffer(threading.Thread):
|
|
110
|
+
"""Collect a fixed number of frames from an :class:`AudioBuffer` in a
|
|
111
|
+
background thread.
|
|
112
|
+
|
|
113
|
+
>>> data = FetchBuffer(buffer).get_data(desired_queue_size=10)
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, buffer: AudioBuffer):
|
|
117
|
+
super().__init__(daemon=True)
|
|
118
|
+
if not isinstance(buffer, AudioBuffer):
|
|
119
|
+
raise TypeError("buffer must be an AudioBuffer")
|
|
120
|
+
self.buffer = buffer
|
|
121
|
+
self.queue_size: Optional[int] = None
|
|
122
|
+
self.data: Optional[np.ndarray] = None
|
|
123
|
+
|
|
124
|
+
def get_data(self, desired_queue_size: int, timeout: Optional[float] = None):
|
|
125
|
+
self.queue_size = desired_queue_size
|
|
126
|
+
self.start()
|
|
127
|
+
return self.join(timeout)
|
|
128
|
+
|
|
129
|
+
def run(self) -> None:
|
|
130
|
+
if isinstance(self.queue_size, int):
|
|
131
|
+
self.data = self.buffer.get_fixed_size_data(self.queue_size)
|
|
132
|
+
|
|
133
|
+
def join(self, timeout: Optional[float] = None):
|
|
134
|
+
super().join(timeout)
|
|
135
|
+
return self.data
|