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
« 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
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)
18from io import StringIO
19from pathlib import Path
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
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
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
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
59class TimePlot(QObject):
61 sigFish = Signal(int)
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))
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()
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()
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()
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()
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()
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()
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()
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)
143class TracePlot(TimePlot):
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')
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()
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()
180class RatePlot(TimePlot):
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')
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()
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()
208class PowerPlot(QObject):
210 sigFish = Signal(int)
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))
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()
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()
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()
285 def onpress(self, event):
286 self.moved = False
288 def onmove(self, event):
289 self.moved = True
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()
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()
346class FrequenciesPlot(QObject):
348 sigEODFreq = Signal(float)
349 sigEODFreqs = Signal(float, float)
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)
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)
378class EODPlot():
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)
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
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)
428class TeeStringIO(StringIO):
429 def __init__(self):
430 super().__init__()
431 self.orig_stdout = sys.stdout
432 self.orig_stdout.flush()
434 def write(self, *args, **kwargs):
435 super().write(*args, **kwargs)
436 self.orig_stdout.write(*args, **kwargs)
439class ThunderfishDialog(QDialog):
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()
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
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)
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')
519 # plots:
520 plt.rcParams['axes.spines.top'] = False
521 plt.rcParams['axes.spines.right'] = False
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')
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
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]
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
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)
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)
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]
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)
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)
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)
650 def toggle_maximize(self):
651 if self.isMaximized():
652 self.showNormal()
653 else:
654 self.showMaximized()
656 def toggle_fullscreen(self):
657 if self.isFullScreen():
658 self.showNormal()
659 else:
660 self.showFullScreen()
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))
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)
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)
692 def home(self):
693 for n in self.navis:
694 n.home()
696 def back(self):
697 for n in self.navis:
698 n.back()
700 def forward(self):
701 for n in self.navis:
702 n.forward()
704 def zoom(self):
705 for n in self.navis:
706 n.zoom()
708 def pan(self):
709 for n in self.navis:
710 n.pan()
712 def toggle_trace(self, index):
713 for act in self.trace_acts:
714 act.setEnabled(index == self.trace_idx)
716 def toggle_rate(self, index):
717 for act in self.rate_acts:
718 act.setEnabled(index == self.rate_idx)
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)()
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])
730 def toggle_spec(self, index):
731 for act in self.spec_acts:
732 act.setEnabled(index == self.spec_idx)
734 def setup_toolbar(self):
735 tools = QToolBar(self)
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)
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)
751 tools.addSeparator()
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)
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)
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)
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)
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)
788 tools.addSeparator()
790 act = QShortcut(QKeySequence('Shift+X'), self)
791 act.activated.connect(self.trace_plot.zoom_ampl_in)
792 self.trace_acts.append(act)
794 act = QShortcut(QKeySequence('x'), self)
795 act.activated.connect(self.trace_plot.zoom_ampl_out)
796 self.trace_acts.append(act)
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)
803 act = QShortcut(QKeySequence('r'), self)
804 act.activated.connect(self.rate_plot.zoom_rate_out)
805 self.rate_acts.append(act)
807 act = QShortcut(QKeySequence('Shift+F'), self)
808 act.activated.connect(self.spec_plot.zoom_freq_in)
809 self.spec_acts.append(act)
811 act = QShortcut(QKeySequence('f'), self)
812 act.activated.connect(self.spec_plot.zoom_freq_out)
813 self.spec_acts.append(act)
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)
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)
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)
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)
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)
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)
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)
871 tools.addSeparator()
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)
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)
886 tools.addSeparator()
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)
895 return tools
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
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)
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)
921class ThunderfishAnalyzer(Analyzer):
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)
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()
946def audian_analyzer(browser):
947 browser.remove_analyzer('plain')
948 browser.remove_analyzer('statistics')
949 ThunderfishAnalyzer(browser)
952def main():
953 plugins = Plugins()
954 plugins.add_analyzer_factory(audian_analyzer)
955 audian_cli(sys.argv[1:], plugins)
958if __name__ == '__main__':
959 main()