Coverage for src/thunderfish/audianthunder.py: 0%

719 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 20:05 +0000

1import os 

2import sys 

3import numpy as np 

4import matplotlib.pyplot as plt 

5 

6try: 

7 from audian.audian import audian_cli 

8 from audian.plugins import Plugins 

9 from audian.analyzer import Analyzer 

10except ImportError: 

11 print() 

12 print('ERROR: You need to install audian (https://github.com/bendalab/audian):') 

13 print() 

14 print('pip install audian') 

15 print() 

16 sys.exit(1) 

17 

18from io import StringIO 

19from pathlib import Path 

20 

21from matplotlib.figure import Figure 

22from matplotlib.backends.backend_qtagg import FigureCanvas 

23from matplotlib.backends.backend_qtagg import \ 

24 NavigationToolbar2QT as NavigationToolbar 

25from matplotlib.backend_bases import MouseButton 

26 

27try: 

28 from PyQt5.QtCore import Signal 

29except ImportError: 

30 from PyQt5.QtCore import pyqtSignal as Signal 

31from PyQt5.QtCore import Qt, QObject, QTime 

32from PyQt5.QtGui import QFont, QKeySequence 

33from PyQt5.QtGui import QIcon, QPixmap, QPainter, QColor 

34from PyQt5.QtWidgets import QDialog, QShortcut, QVBoxLayout, QHBoxLayout 

35from PyQt5.QtWidgets import QWidget, QTabWidget, QToolBar, QAction, QStyle 

36from PyQt5.QtWidgets import QPushButton, QLabel, QScrollArea, QFileDialog 

37 

38from audioio import fade 

39from thunderlab.eventdetection import minmax_threshold, detect_peaks 

40from thunderlab.powerspectrum import decibel, plot_decibel_psd 

41from thunderlab.tabledata import write_table_args 

42 

43from .thunderfish import configuration 

44from .thunderfish import rec_style, spectrum_style 

45from .thunderfish import wave_eod_styles, pulse_eod_styles, snippet_style 

46from .thunderfish import wave_spec_styles, pulse_spec_styles 

47from .bestwindow import clip_args, clip_amplitudes 

48from .harmonics import colors_markers, plot_harmonic_groups 

49from .eodanalysis import detect_eods, plot_eod_snippets 

50from .eodanalysis import plot_eod_recording, zoom_eod_recording, save_analysis 

51from .pulseanalysis import plot_pulse_eodtimes, plot_pulse_rate, plot_pulse_eod 

52from .pulseanalysis import pulsetrain, plot_pulse_spectrum 

53from .waveanalysis import plot_wave_eod, plot_wave_spectrum 

54from .freqanalysis import plot_freq_diffs, plot_freq_ratios, plot_musical_intervals 

55from .harmonics import annotate_harmonic_group 

56from .fakefish import wavefish_eods 

57 

58 

59class TimePlot(QObject): 

60 

61 sigFish = Signal(int) 

62 

63 def __init__(self, time): 

64 super().__init__() 

65 self.full_time_range = (time[0], time[-1]) 

66 self.fish_dict = {} 

67 self.zoomed_time_range = None 

68 self.tfac = 1 

69 self.canvas = FigureCanvas(Figure(figsize=(10, 5), 

70 layout='constrained')) 

71 self.canvas.mpl_connect('pick_event', self.onpick) 

72 self.navi = NavigationToolbar(self.canvas) 

73 self.navi.hide() 

74 self.ax = self.canvas.figure.subplots() 

75 self.rate = 1/np.mean(np.diff(time)) 

76 

77 def toggle_time_range(self): 

78 if self.zoomed_time_range is None: 

79 self.zoomed_time_range = self.ax.get_xlim() 

80 self.ax.set_xlim(*self.full_time_range) 

81 else: 

82 self.ax.set_xlim(*self.zoomed_time_range) 

83 self.zoomed_time_range = None 

84 self.canvas.draw() 

85 

86 def zoom_time_in(self): 

87 t0, t1 = self.ax.get_xlim() 

88 if (t1 - t0)/self.tfac > 0.001: 

89 t1 = t0 + 0.5*(t1 - t0) 

90 self.ax.set_xlim(t0, t1) 

91 self.canvas.draw() 

92 

93 def zoom_time_out(self): 

94 t0, t1 = self.ax.get_xlim() 

95 if t1 < self.full_time_range[1]: 

96 t1 = t0 + 2*(t1 - t0) 

97 if t1 > self.full_time_range[1]: 

98 t1 = self.full_time_range[1] 

99 self.ax.set_xlim(t0, t1) 

100 self.canvas.draw() 

101 

102 def move_backward(self): 

103 t0, t1 = self.ax.get_xlim() 

104 if t0 > self.full_time_range[0]: 

105 dt = 0.5*(t1 - t0) 

106 if t0 - dt < self.full_time_range[0]: 

107 dt = t0 - self.full_time_range[0] 

108 self.ax.set_xlim(t0 - dt, t1 - dt) 

109 self.canvas.draw() 

110 

111 def move_forward(self): 

112 t0, t1 = self.ax.get_xlim() 

113 if t1 < self.full_time_range[1]: 

114 dt = 0.5*(t1 - t0) 

115 if t1 + dt > self.full_time_range[1]: 

116 dt = self.full_time_range[1] - t1 

117 self.ax.set_xlim(t0 + dt, t1 + dt) 

118 self.canvas.draw() 

119 

120 def home(self): 

121 t0, t1 = self.ax.get_xlim() 

122 if t0 > self.full_time_range[0]: 

123 dt = t1 - t0 

124 t0 = self.full_time_range[0] 

125 self.ax.set_xlim(t0, t0 + dt) 

126 self.canvas.draw() 

127 

128 def end(self): 

129 t0, t1 = self.ax.get_xlim() 

130 if t1 < self.full_time_range[1]: 

131 dt = t1 - t0 

132 t1 = self.full_time_range[1] 

133 self.ax.set_xlim(t1 - dt, t1) 

134 self.canvas.draw() 

135 

136 def onpick(self, event): 

137 a = event.artist 

138 if a in self.fish_dict: 

139 inx = self.fish_dict[a] 

140 self.sigFish.emit(inx) 

141 

142 

143class TracePlot(TimePlot): 

144 

145 def __init__(self, time, data, unit, eod_props, wave_eodfs, 

146 pulse_colors, pulse_markers): 

147 super().__init__(time) 

148 twidth = 0.5 

149 self.tfac = plot_eod_recording(self.ax, data, self.rate, unit, 

150 twidth, time[0], rec_style) 

151 self.fish_dict = plot_pulse_eodtimes(self.ax, data, self.rate, 

152 twidth, eod_props, time[0], 

153 colors=pulse_colors, 

154 markers=pulse_markers, 

155 frameon=True, loc='upper right') 

156 zoom_eod_recording(self.ax, eod_props, data, self.rate, 

157 twidth, self.tfac, time[0]) 

158 x0, x1 = self.ax.get_ylim() 

159 self.max_ampl = max(abs(x0), abs(x1)) 

160 if self.ax.get_legend() is not None: 

161 self.ax.get_legend().get_frame().set_color('white') 

162 

163 def zoom_ampl_in(self): 

164 x0, x1 = self.ax.get_ylim() 

165 dx = max(abs(x0), abs(x1)) 

166 if dx/2 < 1e-5*self.max_ampl: 

167 dx = 2e-5*self.max_ampl 

168 self.ax.set_ylim(-dx/2, dx/2) 

169 self.canvas.draw() 

170 

171 def zoom_ampl_out(self): 

172 x0, x1 = self.ax.get_ylim() 

173 dx = max(abs(x0), abs(x1)) 

174 if 2*dx > self.max_ampl: 

175 dx = self.max_ampl/2 

176 self.ax.set_ylim(-2*dx, 2*dx) 

177 self.canvas.draw() 

178 

179 

180class RatePlot(TimePlot): 

181 

182 def __init__(self, time, eod_props, pulse_colors, pulse_markers): 

183 super().__init__(time) 

184 self.fish_dict = plot_pulse_rate(self.ax, eod_props, 

185 toffs=self.full_time_range[0], 

186 colors=pulse_colors, 

187 markers=pulse_markers) 

188 _, self.max_rate = self.ax.get_ylim() 

189 if self.ax.get_legend() is not None: 

190 self.ax.get_legend().get_frame().set_color('white') 

191 

192 def zoom_rate_in(self): 

193 x0, x1 = self.ax.get_ylim() 

194 dx = max(10, x1 - x0) 

195 self.ax.set_ylim(x0, x0 + dx/2) 

196 self.canvas.draw() 

197 

198 def zoom_rate_out(self): 

199 x0, x1 = self.ax.get_ylim() 

200 dx = x1 - x0 

201 if x0 + 2*dx > 2*self.max_rate: 

202 self.ax.set_ylim(x0, 2*self.max_rate) 

203 else: 

204 self.ax.set_ylim(x0, x0 + 2*dx) 

205 self.canvas.draw() 

206 

207 

208class PowerPlot(QObject): 

209 

210 sigFish = Signal(int) 

211 

212 def __init__(self, power_freqs, powers, power_thresh, 

213 wave_eodfs, wave_indices, wave_colors, wave_markers): 

214 super().__init__() 

215 self.deltaf = np.mean(np.diff(power_freqs)) 

216 self.power_freqs = power_freqs 

217 self.powers = powers 

218 self.wave_indices = wave_indices 

219 self.canvas = FigureCanvas(Figure(figsize=(10, 5), 

220 layout='constrained')) 

221 self.harmonics_artists = [] 

222 self.harmonics_div = 1 

223 self.harmonics_freq = 0 

224 self.annotation = [] 

225 self.pick = QTime.currentTime() 

226 self.moved = False 

227 self.canvas.mpl_connect('button_press_event', self.onpress) 

228 self.canvas.mpl_connect('button_release_event', self.onrelease) 

229 self.canvas.mpl_connect('motion_notify_event', self.onmove) 

230 self.canvas.mpl_connect('pick_event', self.onpick) 

231 QShortcut('ESC', self.canvas).activated.connect(self.clear) 

232 self.navi = NavigationToolbar(self.canvas) 

233 self.navi.hide() 

234 self.ax = self.canvas.figure.subplots() 

235 if power_thresh is not None: 

236 self.ax.plot(power_thresh[:, 0], 

237 decibel(power_thresh[:, 1]), 

238 '#CCCCCC', lw=1) 

239 self.wave_dict = {} 

240 self.colors = [] 

241 self.markers = [] 

242 if len(wave_eodfs) > 0: 

243 self.wave_dict, self.colors, self.markers, self.markersizes = \ 

244 plot_harmonic_groups(self.ax, wave_eodfs, 

245 self.wave_indices, max_groups=0, 

246 skip_bad=False, 

247 sort_by_freq=True, 

248 label_power=False, 

249 colors=wave_colors, 

250 markers=wave_markers, 

251 legend_rows=10, frameon=False, 

252 bbox_to_anchor=(1, 1), 

253 loc='upper left') 

254 plot_decibel_psd(self.ax, self.power_freqs, self.powers, 

255 log_freq=False, min_freq=0, max_freq=3000, 

256 ymarg=5.0, sstyle=spectrum_style) 

257 self.ax.yaxis.set_major_locator(plt.MaxNLocator(6)) 

258 

259 def zoom_freq_in(self): 

260 x0, x1 = self.ax.get_xlim() 

261 dx = max(10, x1 - x0) 

262 self.ax.set_xlim(x0, x0 + dx/2) 

263 self.canvas.draw() 

264 

265 def zoom_freq_out(self): 

266 x0, x1 = self.ax.get_xlim() 

267 dx = x1 - x0 

268 if x0 + 2*dx > self.power_freqs[-1]: 

269 self.ax.set_xlim(x0, self.power_freqs[-1]) 

270 else: 

271 self.ax.set_xlim(x0, x0 + 2*dx) 

272 self.canvas.draw() 

273 

274 def onpick(self, event): 

275 self.clear() 

276 a = event.artist 

277 if a in self.wave_dict: 

278 finx, fish = self.wave_dict[a] 

279 self.annotation = annotate_harmonic_group(self.ax, fish, finx, 

280 freq_thresh=0.8*self.deltaf) 

281 self.ax.get_figure().canvas.draw() 

282 self.sigFish.emit(self.wave_indices[finx]) 

283 self.pick = QTime.currentTime() 

284 

285 def onpress(self, event): 

286 self.moved = False 

287 

288 def onmove(self, event): 

289 self.moved = True 

290 

291 def onrelease(self, event): 

292 if self.moved: 

293 return 

294 if self.pick.msecsTo(QTime.currentTime()) < 100: 

295 return 

296 if event.inaxes is not None and event.inaxes == self.ax: 

297 self.clear(True) 

298 pmin, pmax = self.ax.get_ylim() 

299 fmin, fmax = self.ax.get_xlim() 

300 fwidth = fmax - fmin 

301 df = 0.01*fwidth 

302 if abs(self.harmonics_freq - event.xdata) <= df: 

303 self.harmonics_div += 1 

304 else: 

305 self.harmonics_div = 1 

306 f = event.xdata 

307 mask = (self.power_freqs >= f - df) & (self.power_freqs <= f + df) 

308 dbpower = decibel(self.powers[mask]) 

309 thresh = minmax_threshold(dbpower, None, 0.3) 

310 peaks, troughs = detect_peaks(dbpower, thresh) 

311 if len(peaks) == 0: 

312 i = np.argmax(self.powers[mask]) 

313 elif len(peaks) == 1: 

314 i = peaks[0] 

315 else: 

316 i = peaks[np.argmin(np.abs(self.power_freqs[mask][peaks] - f))] 

317 self.harmonics_freq = self.power_freqs[mask][i] 

318 f1 = self.harmonics_freq/self.harmonics_div 

319 for h in range(1, 1000*self.harmonics_div): 

320 if h*f1 > self.power_freqs[-1]: 

321 break 

322 if h == 1: 

323 a = self.ax.text(f1 + 0.01*fwidth, pmax, f'{f1:.1f}Hz', 

324 ha='left', va='top', 

325 bbox=dict(boxstyle='round', 

326 facecolor='white')) 

327 self.harmonics_artists.append(a) 

328 a = self.ax.axvline(h*f1, color='k', lw=1) 

329 self.harmonics_artists.append(a) 

330 self.ax.get_figure().canvas.draw() 

331 

332 def clear(self, keep_div=False): 

333 if len(self.annotation) > 0: 

334 for a in self.annotation: 

335 a.remove() 

336 self.annotation = [] 

337 if len(self.harmonics_artists) > 0: 

338 for a in self.harmonics_artists: 

339 a.remove() 

340 self.harmonics_artists = [] 

341 if not keep_div: 

342 self.harmonics_div = 1 

343 self.ax.get_figure().canvas.draw() 

344 

345 

346class FrequenciesPlot(QObject): 

347 

348 sigEODFreq = Signal(float) 

349 sigEODFreqs = Signal(float, float) 

350 

351 def __init__(self, freqs): 

352 super().__init__() 

353 self.canvas = FigureCanvas(Figure(figsize=(10, 5), 

354 layout='constrained')) 

355 self.canvas.mpl_connect('button_release_event', self.onrelease) 

356 self.navi = NavigationToolbar(self.canvas) 

357 self.navi.hide() 

358 self.axs = self.canvas.figure.subplots(1, 3, sharex=True, sharey=True) 

359 self.freqs = np.sort(freqs) 

360 plot_freq_diffs(self.axs[0], self.freqs) 

361 plot_freq_ratios(self.axs[1], self.freqs) 

362 plot_musical_intervals(self.axs[2], self.freqs) 

363 

364 def onrelease(self, event): 

365 if event.inaxes in self.axs: 

366 r = int(event.ydata) 

367 c = int(event.xdata) 

368 eodf1 = self.freqs[-1 - r] 

369 eodf2 = self.freqs[c] 

370 if event.button == MouseButton.LEFT: 

371 self.sigEODFreq.emit(eodf1) 

372 elif event.button == MouseButton.RIGHT: 

373 self.sigEODFreq.emit(eodf2) 

374 elif event.button == MouseButton.MIDDLE: 

375 self.sigEODFreqs.emit(eodf1, eodf2) 

376 

377 

378class EODPlot(): 

379 

380 def __init__(self, data, rate, mean_eod, spectrum, props, phases, unit): 

381 n_snippets = 10 

382 self.canvas = FigureCanvas(Figure(figsize=(10, 5), 

383 layout='constrained')) 

384 self.navi = NavigationToolbar(self.canvas) 

385 self.navi.hide() 

386 gs = self.canvas.figure.add_gridspec(2, 2) 

387 self.axe = self.canvas.figure.add_subplot(gs[:, 0]) 

388 self.props = props 

389 self.mean_eod = mean_eod 

390 self.spectrum = spectrum 

391 self.duration = len(data)/rate 

392 if self.props['type'] == 'wave': 

393 plot_wave_eod(self.axe, self.mean_eod, self.props, phases, 

394 unit=unit, **wave_eod_styles) 

395 self.axa = self.canvas.figure.add_subplot(gs[0, 1]) 

396 self.axp = self.canvas.figure.add_subplot(gs[1, 1], sharex=self.axa) 

397 plot_wave_spectrum(self.axa, self.axp, self.spectrum, self.props, 

398 unit=unit, **wave_spec_styles) 

399 else: 

400 plot_pulse_eod(self.axe, self.mean_eod, self.props, phases, 

401 unit=unit, **pulse_eod_styles) 

402 if 'times' in self.props: 

403 plot_eod_snippets(self.axe, data, rate, 

404 self.mean_eod[0, 0], self.mean_eod[-1, 0], 

405 self.props['times'], n_snippets, 

406 self.props['flipped'], 

407 self.props['aoffs'], snippet_style) 

408 self.axs = self.canvas.figure.add_subplot(gs[:, 1]) 

409 plot_pulse_spectrum(self.axs, self.spectrum, self.props, 

410 **pulse_spec_styles) 

411 

412 def synthesize(self, rate, duration=2.0): 

413 if self.props['type'] == 'wave': 

414 data = wavefish_eods(self.spectrum, self.spectrum[0, 1], 

415 rate=rate, duration=duration) 

416 else: 

417 data = pulsetrain(self.props['times'], self.mean_eod, 

418 None, self.duration, rate, 0.05) 

419 return data 

420 

421 def play(self, audio): 

422 rate = 44100.0 

423 data = self.synthesize(rate, 2.0) 

424 fade(data, rate, 0.1) 

425 audio.play(data, rate, blocking=False) 

426 

427 

428class TeeStringIO(StringIO): 

429 def __init__(self): 

430 super().__init__() 

431 self.orig_stdout = sys.stdout 

432 self.orig_stdout.flush() 

433 

434 def write(self, *args, **kwargs): 

435 super().write(*args, **kwargs) 

436 self.orig_stdout.write(*args, **kwargs) 

437 

438 

439class ThunderfishDialog(QDialog): 

440 

441 def __init__(self, time, data, unit, ampl_max, 

442 power_freqs, power_times, powers, 

443 channel, file_path, cfg, audio, parent, 

444 *args, **kwargs): 

445 super().__init__(parent, *args, **kwargs) 

446 self.time = time 

447 self.rate = 1/np.mean(np.diff(self.time)) 

448 self.data = data 

449 self.unit = unit 

450 self.ampl_max = ampl_max 

451 self.channel = channel 

452 self.cfg = cfg 

453 self.file_path = file_path 

454 self.navis = [] 

455 self.audio = audio 

456 self.pulse_colors, self.pulse_markers = colors_markers() 

457 self.pulse_colors = self.pulse_colors[3:] 

458 self.pulse_markers = self.pulse_markers[3:] 

459 self.wave_colors, self.wave_markers = colors_markers() 

460 

461 # collect stdout: 

462 orig_stdout = sys.stdout 

463 sys.stdout = TeeStringIO() 

464 # clipping amplitudes: 

465 self.min_clip, self.max_clip = \ 

466 clip_amplitudes(self.data, max_ampl=self.ampl_max, 

467 **clip_args(self.cfg, self.rate)) 

468 # detect EODs in the data: 

469 power_freqs, powers, self.wave_eodfs, self.wave_indices, \ 

470 self.eod_props, self.mean_eods, self.spec_data, self.phase_data, \ 

471 self.pulse_data, power_thresh, self.skip_reason = \ 

472 detect_eods(self.data, self.rate, power_freqs, power_times, powers, 

473 min_clip=self.min_clip, max_clip=self.max_clip, 

474 name=self.file_path, mode='wp', 

475 verbose=2, plot_level=0, cfg=self.cfg) 

476 # add analysis window to EOD properties: 

477 for props in self.eod_props: 

478 props['twin'] = time[0] 

479 props['window'] = time[-1] - time[0] 

480 self.eodfs = np.array([props['EODf'] for props in self.eod_props]) 

481 self.nwave = 0 

482 self.npulse = 0 

483 for i in range(len(self.eod_props)): 

484 if self.eod_props[i]['type'] == 'pulse': 

485 self.npulse += 1 

486 elif self.eod_props[i]['type'] == 'wave': 

487 self.nwave += 1 

488 self.neods = self.nwave + self.npulse 

489 # read out stdout: 

490 log = sys.stdout.getvalue() 

491 sys.stdout = orig_stdout 

492 

493 # dialog: 

494 vbox = QVBoxLayout(self) 

495 self.tabs = QTabWidget(self) 

496 self.tabs.setDocumentMode(True) 

497 self.tabs.setMovable(True) 

498 self.tabs.setTabsClosable(False) 

499 self.trace_acts = [] 

500 self.rate_acts = [] 

501 self.trace_rate_acts = [] 

502 self.spec_acts = [] 

503 self.tabs.currentChanged.connect(self.toggle_trace) 

504 self.tabs.currentChanged.connect(self.toggle_rate) 

505 self.tabs.currentChanged.connect(self.toggle_trace_rate) 

506 self.tabs.currentChanged.connect(self.toggle_spec) 

507 vbox.addWidget(self.tabs) 

508 

509 # log messages: 

510 self.log = QLabel(self) 

511 self.log.setTextInteractionFlags(Qt.TextSelectableByMouse) 

512 self.log.setText(log) 

513 self.log.setFont(QFont('monospace')) 

514 self.log.setMinimumSize(self.log.sizeHint()) 

515 self.scroll = QScrollArea(self) 

516 self.scroll.setWidget(self.log) 

517 self.tabs.addTab(self.scroll, '&Log') 

518 

519 # plots: 

520 plt.rcParams['axes.spines.top'] = False 

521 plt.rcParams['axes.spines.right'] = False 

522 

523 # tab with recording trace: 

524 self.trace_plot = TracePlot(self.time, self.data, self.unit, 

525 self.eod_props, self.wave_eodfs, 

526 self.pulse_colors, self.pulse_markers) 

527 self.trace_plot.sigFish.connect(self.raise_fish) 

528 self.navis.append(self.trace_plot.navi) 

529 self.trace_idx = self.tabs.addTab(self.trace_plot.canvas, '&Trace') 

530 

531 # tab with pulse rates: 

532 if self.npulse > 0: 

533 self.rate_plot = RatePlot(self.time, self.eod_props, 

534 self.pulse_colors, self.pulse_markers) 

535 self.rate_plot.ax.set_xlim(*self.trace_plot.ax.get_xlim()) 

536 self.rate_plot.sigFish.connect(self.raise_fish) 

537 self.navis.append(self.rate_plot.navi) 

538 self.rate_idx = self.tabs.addTab(self.rate_plot.canvas, '&Rate') 

539 else: 

540 self.rate_plot = None 

541 self.rate_idx = None 

542 

543 # tab with power spectrum: 

544 self.spec_plot = PowerPlot(power_freqs, powers, power_thresh, 

545 self.wave_eodfs, self.wave_indices, 

546 self.wave_colors, self.wave_markers) 

547 self.spec_plot.sigFish.connect(self.raise_fish) 

548 self.navis.append(self.spec_plot.navi) 

549 self.spec_idx = self.tabs.addTab(self.spec_plot.canvas, '&Spectrum') 

550 for k, i in enumerate(self.wave_indices): 

551 if i < 0 or \ 

552 self.spec_plot.colors[k] is None or \ 

553 self.spec_plot.markers[k] is None: 

554 continue 

555 self.eod_props[i]['color'] = self.spec_plot.colors[k] 

556 self.eod_props[i]['marker'] = self.spec_plot.markers[k] 

557 self.eod_props[i]['markersize'] = self.spec_plot.markersizes[k] 

558 

559 # tab with frequencies: 

560 if len(self.eodfs) > 1: 

561 self.freqs_plot = FrequenciesPlot(self.eodfs) 

562 self.navis.append(self.freqs_plot.navi) 

563 self.freqs_idx = self.tabs.addTab(self.freqs_plot.canvas, 

564 '&Frequencies') 

565 self.freqs_plot.sigEODFreq.connect(self.raise_and_play) 

566 self.freqs_plot.sigEODFreqs.connect(self.play_interval) 

567 else: 

568 self.freqs_plot = None 

569 self.freqs_idx = None 

570 

571 # set current plot: 

572 if self.nwave > self.npulse: 

573 self.tabs.setCurrentIndex(self.spec_idx) 

574 else: 

575 self.tabs.setCurrentIndex(self.trace_idx) 

576 

577 self.eod_tabs = None 

578 if len(self.eod_props) > 0: 

579 # tabs of EODs: 

580 self.eod_tabs = QTabWidget(self) 

581 self.eod_tabs.setDocumentMode(True) 

582 self.eod_tabs.setMovable(True) 

583 self.eod_tabs.setTabBarAutoHide(False) 

584 self.eod_tabs.setTabsClosable(False) 

585 vbox.addWidget(self.eod_tabs) 

586 

587 # plot EODs: 

588 inx = np.argsort(self.eodfs) 

589 self.eod_plots = [] 

590 for i, k in enumerate(inx): 

591 eod_plot = EODPlot(self.data, self.rate, self.mean_eods[k], 

592 self.spec_data[k], self.eod_props[k], 

593 self.phase_data[k], self.unit) 

594 self.eod_plots.append(eod_plot) 

595 self.navis.append(eod_plot.navi) 

596 icon = self.marker_icon(self.eod_props[k]) 

597 label = f'{i}: {self.eod_props[k]['EODf']:.1f}Hz' 

598 if i < 10: 

599 label = '&' + label 

600 if icon is None: 

601 self.eod_tabs.addTab(eod_plot.canvas, label) 

602 else: 

603 self.eod_tabs.addTab(eod_plot.canvas, icon, label) 

604 # sort EOD frequencies: 

605 self.eodfs = self.eodfs[inx] 

606 

607 self.tools = self.setup_toolbar() 

608 close = QPushButton('&Close', self) 

609 close.pressed.connect(self.accept) 

610 QShortcut('q', self).activated.connect(close.animateClick) 

611 QShortcut('Ctrl+Q', self).activated.connect(close.animateClick) 

612 hbox = QHBoxLayout() 

613 hbox.setContentsMargins(0, 0, 0, 0) 

614 hbox.addWidget(self.tools) 

615 hbox.addWidget(QLabel()) 

616 hbox.addWidget(close) 

617 vbox.addLayout(hbox) 

618 

619 def marker_icon(self, eod, size=32): 

620 if not 'color' in eod or not 'marker' in eod: 

621 return None 

622 radius = size - 2 

623 if 'markersize' in eod: 

624 radius = int(np.round(radius*eod['markersize'])) 

625 offs = (size - radius)//2 

626 pixmap = QPixmap(size, size) 

627 pixmap.fill(Qt.transparent) 

628 painter = QPainter(pixmap) 

629 painter.setRenderHint(QPainter.Antialiasing) # smooth edges 

630 color = QColor(*(eod['color']*255).astype(int)) 

631 painter.setBrush(color) 

632 painter.setPen(Qt.NoPen) # no outline 

633 if eod['marker'] == 'o': 

634 painter.drawEllipse(offs, offs, radius, radius) 

635 else: 

636 painter.end() 

637 print(f'marker "{eod['marker']}" not supported yet') 

638 return None 

639 painter.end() 

640 return QIcon(pixmap) 

641 

642 def resizeEvent(self, event): 

643 if self.eod_tabs is None: 

644 super().resizeEvent(event) 

645 else: 

646 h = (event.size().height() - self.tools.height())//2 - 10 

647 self.tabs.setMaximumHeight(h) 

648 self.eod_tabs.setMaximumHeight(h) 

649 

650 def toggle_maximize(self): 

651 if self.isMaximized(): 

652 self.showNormal() 

653 else: 

654 self.showMaximized() 

655 

656 def toggle_fullscreen(self): 

657 if self.isFullScreen(): 

658 self.showNormal() 

659 else: 

660 self.showFullScreen() 

661 

662 def save(self): 

663 base_name = self.file_path.with_suffix('.zip') 

664 cstr = f'-c{self.channel}' 

665 tstr = f'-t{self.time[0]:.0f}s' 

666 base_name = base_name.with_stem(base_name.stem + cstr + tstr) 

667 filters = ['All files (*)', 'ZIP files (*.zip)'] 

668 base_name = QFileDialog.getSaveFileName(self, 'Save analysis as', 

669 os.fspath(base_name), 

670 ';;'.join(filters))[0] 

671 if base_name: 

672 save_analysis(base_name, True, self.eod_props, 

673 self.mean_eods, self.spec_data, 

674 self.phase_data, self.pulse_data, 

675 self.wave_eodfs, self.wave_indices, self.unit, 0, 

676 **write_table_args(self.cfg)) 

677 

678 def play(self): 

679 if self.audio.active(): 

680 self.audio.stop() 

681 else: 

682 playdata = np.array(self.data) - np.mean(self.data) 

683 fade(playdata, self.rate, 0.1) 

684 self.audio.play(playdata, self.rate, blocking=False) 

685 

686 def play_fish(self): 

687 if self.audio.active(): 

688 self.audio.stop() 

689 else: 

690 self.eod_plots[self.eod_tabs.currentIndex()].play(self.audio) 

691 

692 def home(self): 

693 for n in self.navis: 

694 n.home() 

695 

696 def back(self): 

697 for n in self.navis: 

698 n.back() 

699 

700 def forward(self): 

701 for n in self.navis: 

702 n.forward() 

703 

704 def zoom(self): 

705 for n in self.navis: 

706 n.zoom() 

707 

708 def pan(self): 

709 for n in self.navis: 

710 n.pan() 

711 

712 def toggle_trace(self, index): 

713 for act in self.trace_acts: 

714 act.setEnabled(index == self.trace_idx) 

715 

716 def toggle_rate(self, index): 

717 for act in self.rate_acts: 

718 act.setEnabled(index == self.rate_idx) 

719 

720 def dispatch_trace(self, func): 

721 if self.tabs.currentIndex() in [self.trace_idx, self.rate_idx]: 

722 getattr(self.trace_plot, func)() 

723 if self.rate_plot is not None: 

724 getattr(self.rate_plot, func)() 

725 

726 def toggle_trace_rate(self, index): 

727 for act in self.trace_rate_acts: 

728 act.setEnabled(index in [self.trace_idx, self.rate_idx]) 

729 

730 def toggle_spec(self, index): 

731 for act in self.spec_acts: 

732 act.setEnabled(index == self.spec_idx) 

733 

734 def setup_toolbar(self): 

735 tools = QToolBar(self) 

736 

737 act = QAction('&Play', self) 

738 act.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) 

739 act.setToolTip('Play (Space)') 

740 act.setShortcut(' ') 

741 act.triggered.connect(self.play) 

742 tools.addAction(act) 

743 

744 act = QAction('Play fish', self) 

745 act.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) 

746 act.setToolTip('Play fish (Ctrl + Space)') 

747 act.setShortcut('Ctrl+Space') 

748 act.triggered.connect(self.play_fish) 

749 tools.addAction(act) 

750 

751 tools.addSeparator() 

752 

753 act = QAction('&Home', self) 

754 act.setIcon(self.style().standardIcon(QStyle.SP_DirHomeIcon)) 

755 act.setToolTip('Reset zoom (h, Home)') 

756 act.setShortcuts(['h']) 

757 act.triggered.connect(self.home) 

758 tools.addAction(act) 

759 

760 act = QAction('&Back', self) 

761 act.setIcon(self.style().standardIcon(QStyle.SP_ArrowBack)) 

762 act.setToolTip('Zoom backward (c)') 

763 act.setShortcuts(['c', Qt.Key_Backspace]) 

764 act.triggered.connect(self.back) 

765 tools.addAction(act) 

766 

767 act = QAction('&Forward', self) 

768 act.setIcon(self.style().standardIcon(QStyle.SP_ArrowForward)) 

769 act.setToolTip('Zoom forward (v)') 

770 act.setShortcuts(['v']) 

771 act.triggered.connect(self.forward) 

772 tools.addAction(act) 

773 

774 act = QAction('&Zoom', self) 

775 #act.setIcon(self.style().standardIcon(QStyle.SP_TitleBarMaxButton)) 

776 act.setToolTip('Rectangular zoom (o)') 

777 act.setShortcuts(['o']) 

778 act.triggered.connect(self.zoom) 

779 tools.addAction(act) 

780 

781 act = QAction('&Pan', self) 

782 #act.setIcon(self.style().standardIcon(QStyle.SP_DirHomeIcon)) 

783 act.setToolTip('Pan and zoom (p)') 

784 act.setShortcuts(['p']) 

785 act.triggered.connect(self.pan) 

786 tools.addAction(act) 

787 

788 tools.addSeparator() 

789 

790 act = QShortcut(QKeySequence('Shift+X'), self) 

791 act.activated.connect(self.trace_plot.zoom_ampl_in) 

792 self.trace_acts.append(act) 

793 

794 act = QShortcut(QKeySequence('x'), self) 

795 act.activated.connect(self.trace_plot.zoom_ampl_out) 

796 self.trace_acts.append(act) 

797 

798 if not self.rate_plot is None: 

799 act = QShortcut(QKeySequence('Shift+R'), self) 

800 act.activated.connect(self.rate_plot.zoom_rate_in) 

801 self.rate_acts.append(act) 

802 

803 act = QShortcut(QKeySequence('r'), self) 

804 act.activated.connect(self.rate_plot.zoom_rate_out) 

805 self.rate_acts.append(act) 

806 

807 act = QShortcut(QKeySequence('Shift+F'), self) 

808 act.activated.connect(self.spec_plot.zoom_freq_in) 

809 self.spec_acts.append(act) 

810 

811 act = QShortcut(QKeySequence('f'), self) 

812 act.activated.connect(self.spec_plot.zoom_freq_out) 

813 self.spec_acts.append(act) 

814 

815 act = QAction('Trace', self) 

816 #act.setIcon(self.style().standardIcon(QStyle.SP_DirHomeIcon)) 

817 act.setToolTip('Show all time in trace plot (a)') 

818 act.setShortcuts(['a']) 

819 act.triggered.connect(lambda x: self.dispatch_trace('toggle_time_range')) 

820 tools.addAction(act) 

821 self.trace_rate_acts.append(act) 

822 

823 act = QAction('Home', self) 

824 act.setIcon(self.style().standardIcon(QStyle.SP_MediaSkipBackward)) 

825 act.setToolTip('Skip to beginning of trace plot (Home)') 

826 act.setShortcuts([QKeySequence.MoveToStartOfLine, QKeySequence.MoveToStartOfDocument]) 

827 act.triggered.connect(lambda x: self.dispatch_trace('home')) 

828 tools.addAction(act) 

829 self.trace_rate_acts.append(act) 

830 

831 act = QAction('Seek backward', self) 

832 act.setIcon(self.style().standardIcon(QStyle.SP_MediaSeekBackward)) 

833 act.setToolTip('Seek backward in trace plot (Page up)') 

834 act.setShortcuts([QKeySequence.MoveToPreviousPage]) 

835 act.triggered.connect(lambda x: self.dispatch_trace('move_backward')) 

836 tools.addAction(act) 

837 self.trace_rate_acts.append(act) 

838 

839 act = QAction('Seek forward', self) 

840 act.setIcon(self.style().standardIcon(QStyle.SP_MediaSeekForward)) 

841 act.setToolTip('Seek forward in trace plot (Page down)') 

842 act.setShortcuts([QKeySequence.MoveToNextPage]) 

843 act.triggered.connect(lambda x: self.dispatch_trace('move_forward')) 

844 tools.addAction(act) 

845 self.trace_rate_acts.append(act) 

846 

847 act = QAction('End', self) 

848 act.setIcon(self.style().standardIcon(QStyle.SP_MediaSkipForward)) 

849 act.setToolTip('Skip to end of trace plot (End)') 

850 act.setShortcuts([QKeySequence.MoveToEndOfLine, QKeySequence.MoveToEndOfDocument]) 

851 act.triggered.connect(lambda x: self.dispatch_trace('end')) 

852 tools.addAction(act) 

853 self.trace_rate_acts.append(act) 

854 

855 act = QAction('+', self) 

856 #act.setIcon(self.style().standardIcon(QStyle.SP_DirHomeIcon)) 

857 act.setToolTip('Zoom in to trace plot (+)') 

858 act.setShortcuts([QKeySequence.ZoomIn, '+', '=']) 

859 act.triggered.connect(lambda x: self.dispatch_trace('zoom_time_in')) 

860 tools.addAction(act) 

861 self.trace_rate_acts.append(act) 

862 

863 act = QAction('-', self) 

864 #act.setIcon(self.style().standardIcon(QStyle.SP_DirHomeIcon)) 

865 act.setToolTip('Zoom out of trace plot (-)') 

866 act.setShortcuts([QKeySequence.ZoomOut, '-']) 

867 act.triggered.connect(lambda x: self.dispatch_trace('zoom_time_out')) 

868 tools.addAction(act) 

869 self.trace_rate_acts.append(act) 

870 

871 tools.addSeparator() 

872 

873 act = QAction('&Maximize', self) 

874 act.setIcon(self.style().standardIcon(QStyle.SP_TitleBarMaxButton)) 

875 act.setToolTip('Maximize window (Ctrl+M)') 

876 act.setShortcuts(['Ctrl+M', 'Ctrl+Shift+M']) 

877 act.triggered.connect(self.toggle_maximize) 

878 tools.addAction(act) 

879 

880 act = QAction('&Fullscreen', self) 

881 act.setToolTip('Fullscreen window (Ctrl+F)') 

882 act.setShortcuts(['Ctrl+F']) 

883 act.triggered.connect(self.toggle_fullscreen) 

884 tools.addAction(act) 

885 

886 tools.addSeparator() 

887 

888 act = QAction('&Save', self) 

889 act.setIcon(self.style().standardIcon(QStyle.SP_DialogSaveButton)) 

890 act.setToolTip('Save analysis results to zip file (s)') 

891 act.setShortcuts(['s', 'CTRL+S']) 

892 act.triggered.connect(self.save) 

893 tools.addAction(act) 

894 

895 return tools 

896 

897 def raise_fish(self, inx): 

898 for k, eod_plot in enumerate(self.eod_plots): 

899 if eod_plot.props['index'] == inx: 

900 self.eod_tabs.setCurrentIndex(k) 

901 break 

902 

903 def raise_and_play(self, eodf): 

904 inx = np.argmin(np.abs(self.eodfs - eodf)) 

905 self.eod_tabs.setCurrentIndex(inx) 

906 self.eod_plots[inx].play(self.audio) 

907 

908 def play_interval(self, eodf1, eodf2): 

909 rate = 44100.0 

910 inx1 = np.argmin(np.abs(self.eodfs - eodf1)) 

911 self.eod_tabs.setCurrentIndex(inx1) 

912 data1 = self.eod_plots[inx1].synthesize(rate, 2.0) 

913 inx2 = np.argmin(np.abs(self.eodfs - eodf2)) 

914 data2 = self.eod_plots[inx2].synthesize(rate, 2.0) 

915 n = min(len(data1), len(data2)) 

916 playdata = data1[:n] + data2[:n] 

917 fade(playdata, rate, 0.1) 

918 self.audio.play(playdata, rate, blocking=False) 

919 

920 

921class ThunderfishAnalyzer(Analyzer): 

922 

923 def __init__(self, browser): 

924 super().__init__(browser, 'thunderfish', 'filtered') 

925 self.dialog = None 

926 # configure: 

927 cfgfile = Path(__package__ + '.cfg') 

928 self.cfg = configuration() 

929 self.cfg.load_files(cfgfile, browser.data.file_path, 4) 

930 self.cfg.set('unwrapData', browser.data.data.unwrap) 

931 

932 def analyze(self, t0, t1, channel, traces): 

933 time, data = traces[self.source_name] 

934 freqs = None 

935 spec = None 

936 if 'spectrogram' in traces: 

937 times, freqs, spec = traces['spectrogram'] 

938 dialog = ThunderfishDialog(time, data, self.source.unit, 

939 self.source.ampl_max, 

940 freqs, times, spec, channel, 

941 self.browser.data.file_path, 

942 self.cfg, self.browser.audio, self.browser) 

943 dialog.show() 

944 

945 

946def audian_analyzer(browser): 

947 browser.remove_analyzer('plain') 

948 browser.remove_analyzer('statistics') 

949 ThunderfishAnalyzer(browser) 

950 

951 

952def main(): 

953 plugins = Plugins() 

954 plugins.add_analyzer_factory(audian_analyzer) 

955 audian_cli(sys.argv[1:], plugins) 

956 

957 

958if __name__ == '__main__': 

959 main()