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.
- audio_spectrogram/__init__.py +12 -0
- audio_spectrogram/__main__.py +36 -0
- audio_spectrogram/app/__init__.py +2 -0
- audio_spectrogram/app/application.py +81 -0
- audio_spectrogram/app/control_panel_handler.py +166 -0
- audio_spectrogram/app/plot_handler.py +107 -0
- audio_spectrogram/app/qapplication.py +26 -0
- audio_spectrogram/app/spectrogram.py +62 -0
- audio_spectrogram/app/widgets/__init__.py +2 -0
- audio_spectrogram/app/widgets/combobox.py +39 -0
- audio_spectrogram/app/widgets/control_panel_widget.py +131 -0
- audio_spectrogram/app/widgets/main_window.py +44 -0
- audio_spectrogram/app/widgets/plot_widget.py +58 -0
- audio_spectrogram/app/widgets/power_of_two_spinbox.py +42 -0
- audio_spectrogram/util.py +63 -0
- audio_spectrogram-0.1.0a0.dist-info/METADATA +46 -0
- audio_spectrogram-0.1.0a0.dist-info/RECORD +21 -0
- audio_spectrogram-0.1.0a0.dist-info/WHEEL +5 -0
- audio_spectrogram-0.1.0a0.dist-info/entry_points.txt +5 -0
- audio_spectrogram-0.1.0a0.dist-info/licenses/LICENSE +674 -0
- audio_spectrogram-0.1.0a0.dist-info/top_level.txt +1 -0
|
@@ -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,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,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))
|