audio-spectrogram 0.1.0a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ # Copyright 2026 - 2026, Artur Drogunow and the Audio-Spectrogram contributors
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import logging
5
+ from importlib.metadata import version
6
+ from typing import Final
7
+
8
+ APP_TITLE: Final = "Audio Spectrogram"
9
+ PACKAGE_NAME: Final = "audio-spectrogram"
10
+ LOGGER: Final = logging.getLogger(PACKAGE_NAME)
11
+
12
+ __version__ = version(PACKAGE_NAME)
@@ -0,0 +1,36 @@
1
+ # Copyright 2026 - 2026, Artur Drogunow and the Audio-Spectrogram contributors
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import argparse
5
+ import sys
6
+
7
+ from audio_spectrogram import LOGGER, __version__
8
+ from audio_spectrogram.app.application import Application
9
+ from audio_spectrogram.util import setup_logging
10
+
11
+
12
+ def main() -> None:
13
+ # parse arguments
14
+ parser = argparse.ArgumentParser()
15
+ parser.add_argument(
16
+ "-v",
17
+ "--verbose",
18
+ action="count",
19
+ default=0,
20
+ help="Increase verbosity (-v, -vv, -vvv)",
21
+ )
22
+ args = parser.parse_args()
23
+
24
+ # setup logging
25
+ setup_logging(verbosity=args.verbose)
26
+ LOGGER.info("Executable: %s %s", sys.executable, " ".join(sys.argv))
27
+ LOGGER.info("Version: %s", __version__)
28
+
29
+ while True:
30
+ restart = Application.run_application()
31
+ if not restart:
32
+ break
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
@@ -0,0 +1,2 @@
1
+ # Copyright 2026 - 2026, Artur Drogunow and the Audio-Spectrogram contributors
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -0,0 +1,81 @@
1
+ # Copyright 2026 - 2026, Artur Drogunow and the Audio-Spectrogram contributors
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ from PySide6.QtCore import QEventLoop, QObject
5
+
6
+ from audio_spectrogram import LOGGER
7
+
8
+ from .control_panel_handler import ControlPanelHandler, Settings
9
+ from .plot_handler import PlotHandler
10
+ from .qapplication import get_qapplication
11
+ from .widgets.main_window import MainWindow
12
+
13
+
14
+ class Application(QObject):
15
+ def __init__(self) -> None:
16
+ super().__init__()
17
+
18
+ self.qapp = get_qapplication()
19
+ self.restart = False
20
+ self.main_window = MainWindow()
21
+ self.control_panel_handler = ControlPanelHandler(self.main_window.control_panel_widget)
22
+ self.plot_handler = PlotHandler(self.main_window.plot_widget)
23
+
24
+ self._setup_widgets()
25
+ self._connect_signals()
26
+
27
+ def _setup_widgets(self) -> None:
28
+ pass
29
+
30
+ def _connect_signals(self) -> None:
31
+ self.control_panel_handler.start.connect(self.on_capture_start)
32
+ self.control_panel_handler.stop.connect(self.on_capture_stop)
33
+ self.control_panel_handler.data.connect(self.plot_handler.on_new_data)
34
+
35
+ def on_capture_start(self, settings: Settings) -> None:
36
+ LOGGER.info("%s.%s(%s)", self.__class__.__name__, self.on_capture_start.__name__, settings)
37
+ self.plot_handler.start(
38
+ sample_rate=settings.sample_rate,
39
+ window_size=settings.window_size,
40
+ hop_size=settings.hop_size,
41
+ )
42
+
43
+ def on_capture_stop(self) -> None:
44
+ LOGGER.info("%s.%s()", self.__class__.__name__, self.on_capture_stop.__name__)
45
+ self.plot_handler.stop()
46
+
47
+ def start_gui(self) -> None:
48
+ LOGGER.info("%s.%s()", self.__class__.__name__, self.start_gui.__name__)
49
+ self.main_window.setEnabled(False)
50
+ self.main_window.show()
51
+
52
+ # process events twice to avoid all white main window
53
+ self.qapp.processEvents(QEventLoop.ProcessEventsFlag.AllEvents)
54
+ self.qapp.processEvents(QEventLoop.ProcessEventsFlag.AllEvents)
55
+
56
+ # post-visible setup
57
+ self.control_panel_handler.post_visible_setup()
58
+ self.plot_handler.post_visible_setup()
59
+
60
+ # re-enable window
61
+ self.main_window.setEnabled(True)
62
+
63
+ def cleanup(self) -> None:
64
+ LOGGER.info("%s.%s()", self.__class__.__name__, self.cleanup.__name__)
65
+ self.control_panel_handler.cleanup()
66
+ self.plot_handler.cleanup()
67
+
68
+ @staticmethod
69
+ def run_application() -> bool:
70
+ """Return True to restart application."""
71
+ LOGGER.info("Instantiate GUI")
72
+ app = Application()
73
+ app.start_gui()
74
+
75
+ LOGGER.info("Register cleanup callback")
76
+ app.qapp.aboutToQuit.connect(app.cleanup)
77
+
78
+ LOGGER.info("Run Qt event loop")
79
+ app.qapp.exec()
80
+
81
+ return app.restart
@@ -0,0 +1,166 @@
1
+ # Copyright 2026 - 2026, Artur Drogunow and the Audio-Spectrogram contributors
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ from dataclasses import dataclass
5
+
6
+ import numpy as np
7
+ from PySide6.QtCore import QEventLoop, QIODevice, QObject, Signal
8
+ from PySide6.QtMultimedia import QAudio, QAudioFormat, QAudioSource, QMediaDevices
9
+
10
+ from audio_spectrogram import LOGGER
11
+
12
+ from .qapplication import get_qapplication
13
+ from .widgets.control_panel_widget import ControlPanelWidget
14
+
15
+
16
+ @dataclass
17
+ class Settings:
18
+ sample_rate: int
19
+ window_size: int
20
+ hop_size: int
21
+
22
+
23
+ class ControlPanelHandler(QObject):
24
+ start = Signal(Settings)
25
+ stop = Signal()
26
+ data = Signal(object) # signal data, sample rate
27
+
28
+ def __init__(self, control_panel_widget: ControlPanelWidget) -> None:
29
+ super().__init__()
30
+
31
+ # variables
32
+ self.qapp = get_qapplication()
33
+ self.audio_source: QAudioSource | None = None
34
+ self.io_device: QIODevice | None = None
35
+
36
+ # widgets
37
+ self.control_panel_widget = control_panel_widget
38
+
39
+ # setup
40
+ self._setup_widgets()
41
+ self._setup_layout()
42
+ self._connect_signals()
43
+
44
+ # call after signals are connected
45
+ self.update_device_list()
46
+
47
+ def _setup_widgets(self) -> None:
48
+ self.control_panel_widget.set_state(connected=False)
49
+
50
+ def _setup_layout(self) -> None:
51
+ pass
52
+
53
+ def _connect_signals(self) -> None:
54
+ self.control_panel_widget.device_cb.popoutAboutToBeShown.connect(self.update_device_list)
55
+ self.control_panel_widget.device_cb.currentTextChanged.connect(self.on_device_changed)
56
+ self.control_panel_widget.start_btn.clicked.connect(self.start_capture)
57
+ self.control_panel_widget.stop_btn.clicked.connect(self.stop_capture)
58
+
59
+ def update_device_list(self) -> None:
60
+ LOGGER.info("%s.%s()", self.__class__.__name__, self.update_device_list.__name__)
61
+ device_list = [x.description() for x in QMediaDevices.audioInputs()]
62
+ self.control_panel_widget.set_device_list(device_list)
63
+
64
+ def on_device_changed(self, selected_device: str) -> None:
65
+ LOGGER.info(
66
+ "%s.%s(%s)",
67
+ self.__class__.__name__,
68
+ self.on_device_changed.__name__,
69
+ selected_device,
70
+ )
71
+ supported_sample_rates: set[int] = set()
72
+ for audio_device in QMediaDevices.audioInputs():
73
+ if audio_device.description() != selected_device:
74
+ continue
75
+ fmt = audio_device.preferredFormat()
76
+ preferred_sample_rate = fmt.sampleRate()
77
+ supported_sample_rates.add(preferred_sample_rate)
78
+ for sample_rate in [
79
+ 8_000,
80
+ 11_025,
81
+ 16_000,
82
+ 22_050,
83
+ 44_100,
84
+ 48_000,
85
+ 88_200,
86
+ 96_000,
87
+ 176_400,
88
+ 192_000,
89
+ 352_800,
90
+ 384_000,
91
+ ]:
92
+ fmt.setSampleRate(sample_rate)
93
+ if audio_device.isFormatSupported(fmt):
94
+ supported_sample_rates.add(sample_rate)
95
+ self.control_panel_widget.set_sample_rate_list(
96
+ sample_rates=supported_sample_rates, preferred=preferred_sample_rate
97
+ )
98
+
99
+ def start_capture(self) -> None:
100
+ LOGGER.info("%s.%s()", self.__class__.__name__, self.start_capture.__name__)
101
+ selected_device = self.control_panel_widget.device_cb.currentText()
102
+ for audio_device in QMediaDevices.audioInputs():
103
+ if audio_device.description() != selected_device:
104
+ continue
105
+ fmt = audio_device.preferredFormat()
106
+ fmt.setChannelCount(1)
107
+ fmt.setSampleRate(self.control_panel_widget.sample_rate())
108
+ fmt.setSampleFormat(QAudioFormat.SampleFormat.Float)
109
+ sample_rate = fmt.sampleRate()
110
+ self.audio_source = QAudioSource(audio_device, fmt)
111
+ self.audio_source.stateChanged.connect(self.on_state_changed)
112
+
113
+ # notify
114
+ self.control_panel_widget.set_state(connected=True)
115
+ self.start.emit(
116
+ Settings(
117
+ sample_rate=sample_rate,
118
+ window_size=self.control_panel_widget.window_size(),
119
+ hop_size=self.control_panel_widget.hop_size(),
120
+ )
121
+ )
122
+ self.qapp.processEvents(QEventLoop.ProcessEventsFlag.AllEvents)
123
+
124
+ # start capturing
125
+ LOGGER.info(
126
+ 'Starting stream from device "%s" (%.0fHz)',
127
+ bytes(audio_device.id().data()).decode(),
128
+ self.audio_source.format().sampleRate(),
129
+ )
130
+ self.io_device = self.audio_source.start()
131
+ self.io_device.readyRead.connect(self.on_ready_read)
132
+ self.on_ready_read()
133
+
134
+ def stop_capture(self) -> None:
135
+ LOGGER.info("%s.%s()", self.__class__.__name__, self.stop_capture.__name__)
136
+ if self.io_device is not None:
137
+ self.io_device.readyRead.disconnect(self.on_ready_read)
138
+ self.io_device = None
139
+
140
+ if self.audio_source is not None:
141
+ self.audio_source.stateChanged.disconnect(self.on_state_changed)
142
+ self.audio_source.stop()
143
+ self.audio_source.deleteLater()
144
+ self.audio_source = None
145
+
146
+ self.control_panel_widget.set_state(connected=False)
147
+ self.stop.emit()
148
+
149
+ def on_state_changed(self, state: QAudio.State) -> None:
150
+ LOGGER.info("%s.%s(%s)", self.__class__.__name__, self.on_state_changed.__name__, state)
151
+ if state is QAudio.State.StoppedState:
152
+ self.stop_capture()
153
+
154
+ def on_ready_read(self) -> None:
155
+ LOGGER.debug("%s.%s()", self.__class__.__name__, self.on_ready_read.__name__)
156
+ if self.io_device is not None:
157
+ data = self.io_device.readAll().data()
158
+ array = np.frombuffer(data, dtype=np.float32)
159
+ self.data.emit(array)
160
+
161
+ def post_visible_setup(self) -> None:
162
+ LOGGER.info("%s.%s()", self.__class__.__name__, self.post_visible_setup.__name__)
163
+
164
+ def cleanup(self) -> None:
165
+ LOGGER.info("%s.%s()", self.__class__.__name__, self.cleanup.__name__)
166
+ self.stop_capture()
@@ -0,0 +1,107 @@
1
+ # Copyright 2026 - 2026, Artur Drogunow and the Audio-Spectrogram contributors
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import numpy as np
5
+ import numpy.typing as npt
6
+ from PySide6.QtCore import QObject
7
+
8
+ from audio_spectrogram import LOGGER
9
+
10
+ from .spectrogram import SpectrogramData
11
+ from .widgets.plot_widget import PlotWidget
12
+
13
+
14
+ class PlotHandler(QObject):
15
+ def __init__(self, plot_widget: PlotWidget) -> None:
16
+ super().__init__()
17
+
18
+ # variables
19
+ self.configured = False
20
+ self.recording_duration: float = 20.0 # seconds
21
+ self.time_plot_length: int = 1
22
+ self.time_plot_x: npt.NDArray[np.float32] = np.zeros(self.time_plot_length).astype(np.float32)
23
+ self.time_plot_y: npt.NDArray[np.float32] = np.zeros(self.time_plot_length).astype(np.float32)
24
+ self.spec_plot_data: SpectrogramData | None = None
25
+
26
+ # widgets
27
+ self.plot_widget = plot_widget
28
+
29
+ # setup
30
+ self._setup_widgets()
31
+ self._setup_layout()
32
+ self._connect_signals()
33
+
34
+ def _setup_widgets(self) -> None:
35
+ pass
36
+
37
+ def _setup_layout(self) -> None:
38
+ pass
39
+
40
+ def _connect_signals(self) -> None:
41
+ pass
42
+
43
+ def start(self, sample_rate: int, window_size: int, hop_size: int) -> None:
44
+ LOGGER.info(
45
+ "%s.%s(sample_rate=%d, window_size=%d, hop_size=%d)",
46
+ self.__class__.__name__,
47
+ self.start.__name__,
48
+ sample_rate,
49
+ window_size,
50
+ hop_size,
51
+ )
52
+ # configure timeseries
53
+ self.time_plot_length = round(self.recording_duration * sample_rate)
54
+ self.time_plot_x = np.linspace(-self.recording_duration, 0, self.time_plot_length).astype(
55
+ np.float32
56
+ )
57
+ self.time_plot_y = np.zeros(self.time_plot_length).astype(np.float32)
58
+ self.plot_widget.time_plot_item.setData(x=self.time_plot_x, y=self.time_plot_y)
59
+ self.plot_widget.time_plot.setXRange(-0.1, 0)
60
+
61
+ # configure spectrogram
62
+ self.spec_plot_data = SpectrogramData(
63
+ window_size=window_size,
64
+ hop_size=hop_size,
65
+ frame_count=round(self.recording_duration * sample_rate / hop_size),
66
+ )
67
+ self.plot_widget.spec_plot_item.setImage(self.spec_plot_data.image, autoLevels=False)
68
+ self.plot_widget.spec_plot_item.setRect(
69
+ -self.recording_duration,
70
+ 0,
71
+ self.recording_duration,
72
+ sample_rate / 2,
73
+ )
74
+ self.plot_widget.spec_plot.setYRange(0, sample_rate / 2)
75
+
76
+ self.configured = True
77
+
78
+ def stop(self) -> None:
79
+ LOGGER.info("%s.%s()", self.__class__.__name__, self.stop.__name__)
80
+ self.configured = False
81
+
82
+ def on_new_data(self, data: npt.NDArray[np.float32]) -> None:
83
+ LOGGER.debug("%s.%s(%d bytes)", self.__class__.__name__, self.on_new_data.__name__, data.size)
84
+ if not self.configured or self.spec_plot_data is None:
85
+ return
86
+
87
+ # plot timeseries
88
+ self.time_plot_y = np.hstack((self.time_plot_y, data))[-self.time_plot_length :]
89
+ self.plot_widget.time_plot_item.setData(x=self.time_plot_x, y=self.time_plot_y)
90
+
91
+ # plot spectrogram
92
+ self.spec_plot_data.push(data)
93
+ self.plot_widget.spec_plot_item.setImage(self.spec_plot_data.image, autoLevels=False)
94
+
95
+ def post_visible_setup(self) -> None:
96
+ LOGGER.info("%s.%s()", self.__class__.__name__, self.post_visible_setup.__name__)
97
+ # warmup jit compilation
98
+ spec_plot_data = SpectrogramData(
99
+ window_size=1024,
100
+ hop_size=256,
101
+ frame_count=128,
102
+ )
103
+ spec_plot_data.push(np.zeros(10_000).astype(np.float32))
104
+ self.plot_widget.spec_plot_item.setImage(spec_plot_data.image, autoLevels=False)
105
+
106
+ def cleanup(self) -> None:
107
+ pass
@@ -0,0 +1,26 @@
1
+ # Copyright 2026 - 2026, Artur Drogunow and the Audio-Spectrogram contributors
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ from typing import cast
5
+
6
+ from PySide6.QtWidgets import QApplication
7
+
8
+ from audio_spectrogram import APP_TITLE
9
+
10
+
11
+ def get_qapplication() -> QApplication:
12
+ instance = cast("QApplication | None", QApplication.instance())
13
+ if instance is not None:
14
+ return instance
15
+
16
+ return create_qapplication()
17
+
18
+
19
+ def create_qapplication() -> QApplication:
20
+ # create instance
21
+ qt_app = QApplication()
22
+
23
+ # set app title
24
+ qt_app.setApplicationName(APP_TITLE)
25
+
26
+ return qt_app
@@ -0,0 +1,62 @@
1
+ # Copyright 2026 - 2026, Artur Drogunow and the Audio-Spectrogram contributors
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import numpy as np
5
+ import numpy.typing as npt
6
+ from numba import float32, int64 # import the types
7
+ from numba.experimental import jitclass
8
+
9
+
10
+ @jitclass(
11
+ [
12
+ ("window_size", int64),
13
+ ("hop_size", int64),
14
+ ("frame_count", int64),
15
+ ("freq_bins", int64),
16
+ ("hanning", float32[:]),
17
+ ("scaling", float32[:]),
18
+ ("signal_buffer", float32[:]),
19
+ ("image", float32[:, :]),
20
+ ]
21
+ ) # pyright: ignore[reportCallIssue]
22
+ class SpectrogramData:
23
+ def __init__(self, window_size: int, hop_size: int, frame_count: int) -> None:
24
+ self.window_size = window_size
25
+ self.hop_size = hop_size
26
+ self.frame_count = frame_count
27
+ self.freq_bins = window_size // 2 + 1
28
+ self.hanning = np.hanning(window_size).astype(np.float32)
29
+
30
+ # calculate normalization scaling array
31
+ self.scaling = np.ones(self.freq_bins, dtype=np.float32) / np.sum(self.hanning)
32
+ self.scaling[1:-1] *= 2
33
+
34
+ self.signal_buffer: npt.NDArray[np.float32] = np.zeros((0,), dtype=np.float32)
35
+ self.image: npt.NDArray[np.float32] = np.zeros((self.freq_bins, frame_count), dtype=np.float32)
36
+ self.image -= 120.0
37
+
38
+ def push(self, data: npt.NDArray[np.float32]) -> None:
39
+ self.signal_buffer = np.hstack((self.signal_buffer, data))
40
+ self._calc_stft()
41
+
42
+ def _calc_stft(self) -> None:
43
+ buffer_length = len(self.signal_buffer)
44
+ if buffer_length < self.window_size:
45
+ return
46
+ new_frames = (buffer_length - self.window_size) // self.hop_size + 1
47
+ frame_buffer = np.empty((self.freq_bins, new_frames), dtype=np.float32)
48
+
49
+ for frame_idx in range(new_frames):
50
+ # get window data
51
+ x = self.signal_buffer[
52
+ frame_idx * self.hop_size : frame_idx * self.hop_size + self.window_size
53
+ ]
54
+
55
+ # calculate spectrogram
56
+ mag = np.abs(np.fft.rfft(x * self.hanning))
57
+ mag[:] = np.multiply(mag, self.scaling)
58
+ mag[:] = 20.0 * np.log10(mag + 1e-12)
59
+ frame_buffer[:, frame_idx] = mag
60
+
61
+ self.signal_buffer = self.signal_buffer[new_frames * self.hop_size :]
62
+ self.image = np.hstack((self.image, frame_buffer))[::, -self.frame_count :].copy()
@@ -0,0 +1,2 @@
1
+ # Copyright 2026 - 2026, Artur Drogunow and the Audio-Spectrogram contributors
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
@@ -0,0 +1,39 @@
1
+ # Copyright 2026 - 2026, Artur Drogunow and the Audio-Spectrogram contributors
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import typing
5
+
6
+ from PySide6.QtCore import Qt, Signal
7
+ from PySide6.QtGui import QWheelEvent
8
+ from PySide6.QtWidgets import QComboBox, QWidget
9
+
10
+
11
+ class ComboBoxWithSignal(QComboBox):
12
+ popoutAboutToBeShown = Signal() # noqa: N815
13
+
14
+ def __init__(self, parent: QWidget | None = None) -> None:
15
+ super().__init__(parent)
16
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
17
+
18
+ @typing.override
19
+ def showPopup(self) -> None:
20
+ self.popoutAboutToBeShown.emit()
21
+ self._update_dropdown_width()
22
+ return super().showPopup()
23
+
24
+ @typing.override
25
+ def wheelEvent(self, event: QWheelEvent) -> None:
26
+ if not self.hasFocus():
27
+ event.ignore()
28
+ else:
29
+ super().wheelEvent(event)
30
+
31
+ def _update_dropdown_width(self) -> None:
32
+ view = self.view()
33
+ if self.count() == 0:
34
+ width = self.width()
35
+ else:
36
+ font_metrics = view.fontMetrics()
37
+ width = max(font_metrics.horizontalAdvance(self.itemText(i)) for i in range(self.count()))
38
+ width = max(self.width(), width + 20)
39
+ view.setFixedWidth(width)
@@ -0,0 +1,131 @@
1
+ # Copyright 2026 - 2026, Artur Drogunow and the Audio-Spectrogram contributors
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ from collections.abc import Iterable
5
+ from typing import Final
6
+
7
+ from PySide6.QtCore import QSignalBlocker
8
+ from PySide6.QtWidgets import (
9
+ QComboBox,
10
+ QGroupBox,
11
+ QHBoxLayout,
12
+ QLabel,
13
+ QPushButton,
14
+ QVBoxLayout,
15
+ )
16
+
17
+ from .combobox import ComboBoxWithSignal
18
+ from .power_of_two_spinbox import PowerOfTwoSpinBox
19
+
20
+ OVERLAP_PRESETS: Final = [(f"{2**exponent - 1}/{2**exponent}", 2**exponent) for exponent in range(1, 8)]
21
+
22
+
23
+ class ControlPanelWidget(QGroupBox):
24
+ def __init__(self) -> None:
25
+ super().__init__()
26
+
27
+ # widgets
28
+ self.device_cb = ComboBoxWithSignal()
29
+ self.sample_rate_cb = ComboBoxWithSignal()
30
+ self.window_size_sb = PowerOfTwoSpinBox()
31
+ self.overlap_cb = QComboBox()
32
+ self.start_btn = QPushButton("Start")
33
+ self.stop_btn = QPushButton("Stop")
34
+
35
+ # setup
36
+ self._setup_widgets()
37
+ self._setup_layout()
38
+ self._connect_signals()
39
+
40
+ def _setup_widgets(self) -> None:
41
+ self.setTitle("Control Panel")
42
+
43
+ self.sample_rate_cb.setToolTip(
44
+ "Reduce sample rate for better performance,<br>"
45
+ "increase sample rate to resolve higher frequencies."
46
+ )
47
+ self.window_size_sb.setToolTip(
48
+ "Reduce window size for better temporal resolution,<br>"
49
+ "increase window size for better frequency resolution."
50
+ )
51
+ self.overlap_cb.setToolTip(
52
+ "Higher overlap improves temporal smoothness,<br>but increases CPU usage."
53
+ )
54
+ for label, exponent in OVERLAP_PRESETS:
55
+ self.overlap_cb.addItem(label, exponent)
56
+ self.overlap_cb.setCurrentText("3/4")
57
+
58
+ def _setup_layout(self) -> None:
59
+ vbox = QVBoxLayout(self)
60
+
61
+ hbox1 = QHBoxLayout()
62
+ hbox1.addWidget(QLabel("Capture device"), 0)
63
+ hbox1.addWidget(self.device_cb, 1)
64
+ hbox1.addWidget(QLabel("Sample rate"), 0)
65
+ hbox1.addWidget(self.sample_rate_cb, 0)
66
+ vbox.addLayout(hbox1)
67
+
68
+ hbox2 = QHBoxLayout()
69
+ hbox2.addWidget(QLabel("FFT window size"), 0)
70
+ hbox2.addWidget(self.window_size_sb, 0)
71
+ hbox2.addWidget(QLabel("Overlap"), 0)
72
+ hbox2.addWidget(self.overlap_cb, 0)
73
+ hbox2.addStretch(1)
74
+ hbox2.addWidget(self.start_btn, 0)
75
+ hbox2.addWidget(self.stop_btn, 0)
76
+ vbox.addLayout(hbox2)
77
+
78
+ def _connect_signals(self) -> None:
79
+ pass
80
+
81
+ def set_device_list(self, device_list: list[str]) -> None:
82
+ old_text = self.device_cb.currentText()
83
+
84
+ with QSignalBlocker(self.device_cb):
85
+ self.device_cb.clear()
86
+ self.device_cb.addItems(device_list)
87
+
88
+ if old_text in device_list:
89
+ self.device_cb.setCurrentText(old_text)
90
+
91
+ new_text = self.device_cb.currentText()
92
+
93
+ if new_text != old_text:
94
+ self.device_cb.currentTextChanged.emit(new_text)
95
+
96
+ def set_sample_rate_list(self, sample_rates: Iterable[int], preferred: int) -> None:
97
+ old_text = self.sample_rate_cb.currentText()
98
+
99
+ with QSignalBlocker(self.sample_rate_cb):
100
+ sample_rate_list = [str(x) for x in sorted(sample_rates)]
101
+ self.sample_rate_cb.clear()
102
+ self.sample_rate_cb.addItems(sample_rate_list)
103
+
104
+ if old_text in sample_rate_list:
105
+ self.sample_rate_cb.setCurrentText(old_text)
106
+ else:
107
+ self.sample_rate_cb.setCurrentText(str(preferred))
108
+
109
+ new_text = self.sample_rate_cb.currentText()
110
+
111
+ if new_text != old_text:
112
+ self.sample_rate_cb.currentTextChanged.emit(new_text)
113
+
114
+ def set_state(self, *, connected: bool) -> None:
115
+ self.device_cb.setEnabled(not connected)
116
+ self.sample_rate_cb.setEnabled(not connected)
117
+ self.window_size_sb.setEnabled(not connected)
118
+ self.overlap_cb.setEnabled(not connected)
119
+ self.start_btn.setEnabled(not connected)
120
+ self.stop_btn.setEnabled(connected)
121
+
122
+ def sample_rate(self) -> int:
123
+ return int(self.sample_rate_cb.currentText())
124
+
125
+ def window_size(self) -> int:
126
+ return self.window_size_sb.value()
127
+
128
+ def hop_size(self) -> int:
129
+ window_size = self.window_size()
130
+ divisor = self.overlap_cb.currentData()
131
+ return max(1, int(window_size // divisor))