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

1"""Buffered time-series data. 

2 

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""" 

6 

7 

8import numpy as np 

9 

10 

11def blocks(data, block_size, noverlap=0, start=0, stop=None): 

12 """Generator for blockwise processing of array data. 

13 

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. 

26 

27 Yields 

28 ------ 

29 data: ndarray 

30 Successive slices of the input data. 

31 

32 Raises 

33 ------ 

34 ValueError 

35 `noverlap` larger or equal to `block_size`. 

36 

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 ``` 

54 

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 ``` 

66 

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] 

81 

82 

83class BufferedArray(object): 

84 """Random access to time-series data of which only a part is held in memory. 

85  

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. 

97 

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`. 

104 

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()`: 

108 

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 ``` 

118 

119 or provide all this information via the constructor: 

120  

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. 

136 

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. 

171 

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. 

184 

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__` . 

194 

195 """ 

196 

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() 

213 

214 

215 def __enter__(self): 

216 return self 

217 

218 

219 def __exit__(self, ex_type, ex_value, tb): 

220 self.__del__() 

221 return (ex_value is None) 

222 

223 

224 def __len__(self): 

225 return self.frames 

226 

227 

228 def __iter__(self): 

229 self.iter_counter = -1 

230 return self 

231 

232 

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] 

240 

241 

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] 

291 

292 

293 def blocks(self, block_size, noverlap=0, start=0, stop=None): 

294 """Generator for blockwise processing of AudioLoader data. 

295 

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. 

306 

307 Yields 

308 ------ 

309 data: ndarray 

310 Successive slices of the data managed by AudioLoader. 

311 

312 Raises 

313 ------ 

314 ValueError 

315 `noverlap` larger or equal to `block_size`. 

316 

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) 

331 

332 

333 def init_buffer(self): 

334 """Allocate a buffer with zero frames but all the channels. 

335 

336 Fix `bufferframes` and `backframes` to not exceed the total 

337 number of frames. 

338 

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) 

351 

352 

353 def allocate_buffer(self, nframes=None, force=False): 

354 """Reallocate the buffer to have the right size. 

355 

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) 

376 

377 

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)}') 

386 

387 

388 def update_buffer(self, start, stop): 

389 """Make sure that the buffer contains data of a range of indices. 

390 

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) 

400 

401 

402 def update_time(self, start, stop): 

403 """Make sure that the buffer contains data of a given time range. 

404 

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) 

413 

414 

415 def move_buffer(self, offset, nframes): 

416 """Move and resize buffer. 

417 

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}') 

440 

441 

442 def buffer_position(self, start, stop): 

443 """Compute position and size of buffer. 

444 

445 You usually should not need to call this function 

446 directly. This is handled by `update_buffer()`. 

447 

448 Takes `bufferframes` and `backframes` into account. 

449 

450 Parameters 

451 ---------- 

452 start: int 

453 Index of the first requested frame. 

454 stop: int 

455 Index of the last requested frame. 

456 

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. 

463 

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) 

523 

524 

525 def recycle_buffer(self, offset, nframes): 

526 """Move buffer to new position and recycle content if possible. 

527 

528 You usually should not need to call this function 

529 directly. This is handled by `update_buffer()`. 

530 

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. 

534 

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. 

541 

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. 

548 

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 

579