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 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
@@ -0,0 +1,4 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Single source of truth for the package version."""
3
+ __version__ = "3.0.0"
4
+ __author__ = "RL"
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