Coverage for src/audioio/bufferedarray.py: 83%
215 statements
« prev ^ index » next coverage.py v7.6.3, created at 2024-10-15 07:29 +0000
« prev ^ index » next coverage.py v7.6.3, created at 2024-10-15 07:29 +0000
1"""Buffered time-series data.
3- `blocks()`: generator for blockwise processing of array data.
4- class `BufferedArray()`: random access to time-series data of which only a part is held in memory.
5"""
8import numpy as np
11def blocks(data, block_size, noverlap=0, start=0, stop=None):
12 """Generator for blockwise processing of array data.
14 Parameters
15 ----------
16 data: ndarray
17 Data to loop over. First dimension is time.
18 block_size: int
19 Len of data blocks to be returned.
20 noverlap: int
21 Number of indices successive data points should overlap.
22 start: int
23 Optional first index from which on to return blocks of data.
24 stop: int
25 Optional last index until which to return blocks of data.
27 Yields
28 ------
29 data: ndarray
30 Successive slices of the input data.
32 Raises
33 ------
34 ValueError
35 `noverlap` larger or equal to `block_size`.
37 Examples
38 --------
39 ```
40 import numpy as np
41 from audioio import blocks
42 data = np.arange(20)
43 for x in blocks(data, 6, 2):
44 print(x)
45 ```
46 results in
47 ```text
48 [0 1 2 3 4 5]
49 [4 5 6 7 8 9]
50 [ 8 9 10 11 12 13]
51 [12 13 14 15 16 17]
52 [16 17 18 19]
53 ```
55 Use it for processing long audio data, like computing a
56 spectrogram with overlap:
57 ```
58 from scipy.signal import spectrogram
59 from audioio import AudioLoader, blocks
60 nfft = 2048
61 with AudioLoader('some/audio.wav') as data:
62 for x in blocks(data, 100*nfft, nfft//2):
63 f, t, Sxx = spectrogram(x, fs=data.rate,
64 nperseg=nfft, noverlap=nfft//2)
65 ```
67 """
68 if noverlap >= block_size:
69 raise ValueError(f'noverlap={noverlap} larger than block_size={block_size}')
70 if stop is None:
71 stop = len(data)
72 step = block_size - noverlap
73 n = (stop - start - noverlap)//step
74 if n == 0:
75 yield data[start:stop]
76 else:
77 for k in range(n):
78 yield data[start + k*step:start + k*step + block_size]
79 if stop - start - (k*step + block_size) > 0:
80 yield data[start + (k + 1)*step:stop]
83class BufferedArray(object):
84 """Random access to time-series data of which only a part is held in memory.
86 This is a base class for accessing large audio recordings either
87 from a file (class ` AudioLoader`) or by computing its contents on
88 the fly (e.g. filtered data, envelopes or spectrograms). The
89 `BufferedArray` behaves like a single big ndarray with first
90 dimension indexing the frames and second dimension indexing the
91 channels of the data. Higher dimensions are also supported. For
92 example, a third dimension for frequencies needed for
93 spectrograms. Internally the class holds only a part of the data
94 in memory. The size of this buffer is set to `bufferframes`
95 frames. If more data are requested, the buffer is enlarged
96 accordingly.
98 Classes inheriting `BufferedArray` just need to implement
99 ```
100 self.load_buffer(offset, nsamples, pbuffer)
101 ```
102 This function needs to load the supplied `pbuffer` with
103 `nframes` frames of data starting at frame `offset`.
105 In the constructor or some kind of opening function, you need to
106 set the following member variables, followed by a call to
107 `init_buffer()`:
109 ```
110 self.rate # number of frames per second
111 self.channels # number of channels per frame
112 self.frames # total number of frames
113 self.shape = (self.frames, self.channels, ...)
114 self.bufferframes # number of frames the buffer should hold
115 self.backframes # number of frames kept for moving back
116 self.init_buffer()
117 ```
119 or provide all this information via the constructor:
121 Parameters
122 ----------
123 rate: float
124 The sampling rate of the data in seconds.
125 channels: int
126 The number of channels.
127 frames: int
128 The number of frames.
129 bufferframes: int
130 Number of frames the curent data buffer holds.
131 backframes: int
132 Number of frames the curent data buffer should keep
133 before requested data ranges.
134 verbose: int
135 If larger than zero show detailed error/warning messages.
137 Attributes
138 ----------
139 rate: float
140 The sampling rate of the data in seconds.
141 channels: int
142 The number of channels.
143 frames: int
144 The number of frames. Same as `len()`.
145 shape: tuple
146 Frames and channels of the data. Optional higher dimensions.
147 ndim: int
148 Number of dimensions: 2 (frames and channels) or higher.
149 size: int
150 Total number of samples: frames times channels.
151 offset: int
152 Index of first frame in the current buffer.
153 buffer: ndarray of floats
154 The curently available data. First dimension is time, second channels.
155 Optional higher dimensions according to `ndim` and `shape`.
156 bufferframes: int
157 Number of samples the curent data buffer holds.
158 backframes: int
159 Number of samples the curent data buffer should keep
160 before requested data ranges.
161 follow: int
162 If zero (default), move buffer position only for requests outside
163 the current buffer.
164 If larger than zero then buffer position follows requested data ranges
165 if buffer can be moved by more than `follow`frames.
166 This results in more frequent but smaller buffer updates.
167 Set it after calling the constructor or `init_buffer()`.
168 buffer_changed: ndarray of bool
169 For each channel a flag, whether the buffer content has been changed.
170 Set to `True`, whenever `load_buffer()` was called.
172 Methods
173 -------
174 - `len()`: Number of frames.
175 - `__getitem__`: Access data.
176 - `blocks()`: Generator for blockwise processing of AudioLoader data.
177 - `update_buffer()`: make sure that the buffer contains data of a range of indices.
178 - `update_time()`: make sure that the buffer contains data of a given time range.
179 - `reload_buffer()`: reload the current buffer.
180 - `load_buffer()`: load a range of samples into a buffer.
181 - `move_buffer()`: move and resize buffer.
182 - `buffer_position()`: compute position and size of buffer.
183 - `recycle_buffer()`: move buffer to new position and recycle content if possible.
185 Notes
186 -----
187 Access via `__getitem__` or `__next__` is slow!
188 Even worse, using numpy functions on this class first converts
189 it to a numpy array - that is something we actually do not want!
190 We should subclass directly from numpy.ndarray .
191 For details see http://docs.scipy.org/doc/numpy/user/basics.subclassing.html
192 When subclassing, there is an offset argument, that might help to
193 speed up `__getitem__` .
195 """
197 def __init__(self, rate=0, channels=0, frames=0, bufferframes=0,
198 backframes=0, verbose=0):
199 """ Construtor for initializing 2D arrays (times x channels).
200 """
201 self.rate = rate
202 self.channels = channels
203 self.frames = frames
204 self.shape = (self.frames, self.channels)
205 self.ndim = 2
206 self.size = self.frames * self.channels
207 self.bufferframes = bufferframes # number of frames the buffer can hold
208 self.backframes = backframes # number of frames kept before
209 self.follow = 0
210 self.verbose = verbose
211 self.offset = 0 # index of first frame in buffer
212 self.init_buffer()
215 def __enter__(self):
216 return self
219 def __exit__(self, ex_type, ex_value, tb):
220 self.__del__()
221 return (ex_value is None)
224 def __len__(self):
225 return self.frames
228 def __iter__(self):
229 self.iter_counter = -1
230 return self
233 def __next__(self):
234 self.iter_counter += 1
235 if self.iter_counter >= self.frames:
236 raise StopIteration
237 else:
238 self.update_buffer(self.iter_counter, self.iter_counter + 1)
239 return self.buffer[self.iter_counter - self.offset]
242 def __getitem__(self, key):
243 """Access data of the audio file."""
244 if type(key) is tuple:
245 index = key[0]
246 else:
247 index = key
248 if isinstance(index, slice):
249 start = index.start
250 stop = index.stop
251 step = index.step
252 if start is None:
253 start = 0
254 else:
255 start = int(start)
256 if start < 0:
257 start += len(self)
258 if stop is None:
259 stop = len(self)
260 else:
261 stop = int(stop)
262 if stop < 0:
263 stop += len(self)
264 if stop > self.frames:
265 stop = self.frames
266 if step is None:
267 step = 1
268 else:
269 step = int(step)
270 self.update_buffer(start, stop)
271 newindex = slice(start - self.offset, stop - self.offset, step)
272 elif hasattr(index, '__len__'):
273 index = [inx if inx >= 0 else inx + len(self) for inx in index]
274 start = min(index)
275 stop = max(index)
276 self.update_buffer(start, stop + 1)
277 newindex = [inx - self.offset for inx in index]
278 else:
279 if index > self.frames:
280 raise IndexError
281 index = int(index)
282 if index < 0:
283 index += len(self)
284 self.update_buffer(index, index + 1)
285 newindex = index - self.offset
286 if type(key) is tuple:
287 newkey = (newindex,) + key[1:]
288 return self.buffer[newkey]
289 else:
290 return self.buffer[newindex]
293 def blocks(self, block_size, noverlap=0, start=0, stop=None):
294 """Generator for blockwise processing of AudioLoader data.
296 Parameters
297 ----------
298 block_size: int
299 Len of data blocks to be returned.
300 noverlap: int
301 Number of indices successive data points should overlap.
302 start: int
303 Optional first index from which on to return blocks of data.
304 stop: int
305 Optional last index until which to return blocks of data.
307 Yields
308 ------
309 data: ndarray
310 Successive slices of the data managed by AudioLoader.
312 Raises
313 ------
314 ValueError
315 `noverlap` larger or equal to `block_size`.
317 Examples
318 --------
319 Use it for processing long audio data, like computing a spectrogram with overlap:
320 ```
321 from scipy.signal import spectrogram
322 from audioio import AudioLoader # AudioLoader is a BufferedArray
323 nfft = 2048
324 with AudioLoader('some/audio.wav') as data:
325 for x in data.blocks(100*nfft, nfft//2):
326 f, t, Sxx = spectrogram(x, fs=data.rate,
327 nperseg=nfft, noverlap=nfft//2)
328 ```
329 """
330 return blocks(self, block_size, noverlap, start, stop)
333 def init_buffer(self):
334 """Allocate a buffer with zero frames but all the channels.
336 Fix `bufferframes` and `backframes` to not exceed the total
337 number of frames.
339 """
340 self.ndim = len(self.shape)
341 self.size = self.frames * self.channels
342 if self.bufferframes > self.frames:
343 self.bufferframes = self.frames
344 self.backframes = 0
345 shape = list(self.shape)
346 shape[0] = 0
347 self.buffer = np.empty(shape)
348 self.offset = 0
349 self.follow = 0
350 self.buffer_changed = np.zeros(self.channels, dtype=bool)
353 def allocate_buffer(self, nframes=None, force=False):
354 """Reallocate the buffer to have the right size.
356 Parameters
357 ----------
358 nframes: int or None
359 Number of frames the buffer should hold.
360 If None, use `self.bufferframes`.
361 force: bool
362 If True, reallocate buffer even if it has the same size as before.
363 """
364 if self.bufferframes > self.frames:
365 self.bufferframes = self.frames
366 self.backframes = 0
367 if nframes is None:
368 nframes = self.bufferframes
369 if nframes == 0:
370 return
371 if force or nframes != len(self.buffer) or \
372 self.shape[1:] != self.buffer.shape[1:]:
373 shape = list(self.shape)
374 shape[0] = nframes
375 self.buffer = np.empty(shape)
378 def reload_buffer(self):
379 """Reload the current buffer.
380 """
381 if len(self.buffer) > 0:
382 self.load_buffer(self.offset, len(self.buffer), self.buffer)
383 self.buffer_changed[:] = True
384 if self.verbose > 1:
385 print(f' reloaded {len(self.buffer)} frames from {self.offset} up to {self.offset + len(self.buffer)}')
388 def update_buffer(self, start, stop):
389 """Make sure that the buffer contains data of a range of indices.
391 Parameters
392 ----------
393 start: int
394 Index of the first requested frame.
395 stop: int
396 Index of the last requested frame.
397 """
398 offset, nframes = self.buffer_position(start, stop)
399 self.move_buffer(offset, nframes)
402 def update_time(self, start, stop):
403 """Make sure that the buffer contains data of a given time range.
405 Parameters
406 ----------
407 start: float
408 Time point of first requested frame.
409 stop: int
410 Time point of last requested frame.
411 """
412 self.update_buffer(int(start*self.rate), int(stop*self.rate) + 1)
415 def move_buffer(self, offset, nframes):
416 """Move and resize buffer.
418 Parameters
419 ----------
420 offset: int
421 Frame index of the first frame in the new buffer.
422 nframes: int
423 Number of frames the new buffer should hold.
424 """
425 if offset < 0:
426 offset = 0
427 if offset + nframes > self.frames:
428 nframes = self.frames - offset
429 if offset != self.offset or nframes != len(self.buffer):
430 r_offset, r_nframes = self.recycle_buffer(offset, nframes)
431 self.offset = offset
432 if r_nframes > 0:
433 # load buffer content, this is backend specific:
434 pbuffer = self.buffer[r_offset - self.offset:
435 r_offset - self.offset + r_nframes]
436 self.load_buffer(r_offset, r_nframes, pbuffer)
437 self.buffer_changed[:] = True
438 if self.verbose > 1:
439 print(f' loaded {len(pbuffer)} frames from {r_offset} up to {r_offset + r_nframes}')
442 def buffer_position(self, start, stop):
443 """Compute position and size of buffer.
445 You usually should not need to call this function
446 directly. This is handled by `update_buffer()`.
448 Takes `bufferframes` and `backframes` into account.
450 Parameters
451 ----------
452 start: int
453 Index of the first requested frame.
454 stop: int
455 Index of the last requested frame.
457 Returns
458 -------
459 offset: int
460 Frame index of the first frame in the new buffer.
461 nframes: int
462 Number of frames the new buffer should hold.
464 """
465 if start < 0:
466 start = 0
467 if stop > self.frames:
468 stop = self.frames
469 offset = start
470 nframes = stop - start
471 if start < self.offset or stop > self.offset + len(self.buffer):
472 # we need to move the buffer:
473 if nframes < self.bufferframes:
474 # find optimal new position of buffer that accomodates start:stop
475 back = self.backframes
476 if self.bufferframes - nframes < 2*back:
477 back = (self.bufferframes - nframes)//2
478 offset -= back
479 nframes = self.bufferframes
480 if offset < 0:
481 offset = 0
482 if offset + nframes > self.frames:
483 offset = self.frames - nframes
484 if offset < 0:
485 offset = 0
486 nframes = self.frames - offset
487 # expand buffer to accomodate nearby beginning or end:
488 elif self.frames - offset - nframes < self.bufferframes//2:
489 nframes = self.frames - offset
490 elif offset < self.bufferframes//2:
491 nframes += offset
492 offset = 0
493 if self.verbose > 2:
494 print(f' request {nframes:6d} frames at {offset}-{offset+nframes}')
495 return offset, nframes
496 elif self.follow > 0 and \
497 nframes < len(self.buffer) and \
498 abs(start - self.offset - self.backframes) >= self.follow:
499 offset = start - self.backframes
500 nframes = len(self.buffer)
501 if offset < 0:
502 offset = 0
503 if offset + nframes > self.frames:
504 offset = self.frames - nframes
505 if offset < 0:
506 offset = 0
507 nframes = self.frames - offset
508 # expand buffer to accomodate nearby beginning or end:
509 elif self.frames - offset - nframes < self.bufferframes//2:
510 nframes = self.frames - offset
511 elif offset < self.bufferframes//2:
512 nframes += offset
513 offset = 0
514 if start < offset:
515 print('invalid buffer start', start, offset)
516 if stop > offset + nframes:
517 print('invalid buffer end', stop, offset + nframes)
518 if self.verbose > 2:
519 print(f' request {nframes:6d} frames at {offset}-{offset+nframes}')
520 return offset, nframes
521 # no need to move buffer:
522 return self.offset, len(self.buffer)
525 def recycle_buffer(self, offset, nframes):
526 """Move buffer to new position and recycle content if possible.
528 You usually should not need to call this function
529 directly. This is handled by `update_buffer()`.
531 Move already existing parts of the buffer to their new position (as
532 returned by `buffer_position()`) and return position and size of
533 data chunk that still needs to be loaded from file.
535 Parameters
536 ----------
537 offset: int
538 Frame index of the new first frame in the buffer.
539 nframes: int
540 Number of frames the new buffer should hold.
542 Returns
543 -------
544 r_offset: int
545 First frame to be read from file.
546 r_nframes: int
547 Number of frames to be read from file.
549 """
550 r_offset = offset
551 r_nframes = nframes
552 if (offset >= self.offset and
553 offset < self.offset + len(self.buffer)):
554 i = offset - self.offset
555 n = len(self.buffer) - i
556 if n > nframes:
557 n = nframes
558 tmp_buffer = self.buffer[i:i + n]
559 self.allocate_buffer(nframes)
560 self.buffer[:n] = tmp_buffer
561 r_offset += n
562 r_nframes -= n
563 if self.verbose > 2:
564 print(f' recycle {n:6d} frames from {self.offset + i} - {self.offset + i + n} of the old to the front at {offset} - {offset + n} ({0} - {n} in buffer)')
565 elif (offset + nframes > self.offset and
566 offset + nframes <= self.offset + len(self.buffer)):
567 n = offset + nframes - self.offset
568 m = len(self.buffer)
569 tmp_buffer = self.buffer[:n]
570 self.allocate_buffer(nframes)
571 self.buffer[-n:] = tmp_buffer
572 r_nframes -= n
573 if self.verbose > 2:
574 print(f' recycle {n:6d} frames from {self.offset} - {self.offset + n} of the old {m}-sized buffer to the end at {offset + nframes - n} - {offset + nframes} ({nframes - n} - {nframes} in buffer)')
575 else:
576 # new buffer is somewhere else or larger than current buffer:
577 self.allocate_buffer(nframes)
578 return r_offset, r_nframes