Skip to content

Commit 4cb8390

Browse files
committed
DOC: Add simple GUI example
1 parent 422d9e6 commit 4cb8390

1 file changed

Lines changed: 238 additions & 0 deletions

File tree

examples/rec_gui.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
#!/usr/bin/env python3
2+
"""Simple GUI for recording into a WAV file.
3+
4+
There are 3 concurrent activities: GUI, audio callback, file-writing thread.
5+
6+
Neither the GUI nor the audio callback is supposed to block.
7+
Blocking in any of the GUI functions could make the GUI "freeze", blocking in
8+
the audio callback could lead to drop-outs in the recording.
9+
Blocking the file-writing thread for some time is no problem, as long as the
10+
recording can be stopped successfully when it is supposed to.
11+
12+
"""
13+
import contextlib
14+
import queue
15+
import sys
16+
import tempfile
17+
import threading
18+
import tkinter as tk
19+
from tkinter import ttk
20+
from tkinter.simpledialog import Dialog
21+
22+
import numpy as np
23+
import sounddevice as sd
24+
import soundfile as sf
25+
26+
27+
def file_writing_thread(*, q, **soundfile_args):
28+
"""Write data from queue to file until *None* is recieved."""
29+
# NB: If you want fine-grained control about the buffering of the file, you
30+
# can use Python's open() function (with the "buffering" argument) and
31+
# pass the resulting file object to sf.SoundFile().
32+
with sf.SoundFile(**soundfile_args) as f:
33+
while True:
34+
data = q.get()
35+
if data is None:
36+
break
37+
f.write(data)
38+
39+
40+
class SettingsWindow(Dialog):
41+
"""Dialog window for choosing sound device."""
42+
43+
def body(self, master):
44+
ttk.Label(master, text='Select host API:').pack(anchor='w')
45+
hostapi_list = ttk.Combobox(master, state='readonly', width=50)
46+
hostapi_list.pack()
47+
hostapi_list['values'] = [hostapi['name']
48+
for hostapi in sd.query_hostapis()]
49+
50+
ttk.Label(master, text='Select sound device:').pack(anchor='w')
51+
device_ids = []
52+
device_list = ttk.Combobox(master, state='readonly', width=50)
53+
device_list.pack()
54+
55+
def update_device_list(*args):
56+
hostapi = sd.query_hostapis(hostapi_list.current())
57+
nonlocal device_ids
58+
device_ids = [
59+
idx
60+
for idx in hostapi['devices']
61+
if sd.query_devices(idx)['max_output_channels'] > 0]
62+
device_list['values'] = [
63+
sd.query_devices(idx)['name'] for idx in device_ids]
64+
default = hostapi['default_output_device']
65+
if default >= 0:
66+
device_list.current(device_ids.index(default))
67+
device_list.event_generate('<<ComboboxSelected>>')
68+
69+
def select_device(*args):
70+
self.result = device_ids[device_list.current()]
71+
72+
hostapi_list.bind('<<ComboboxSelected>>', update_device_list)
73+
device_list.bind('<<ComboboxSelected>>', select_device)
74+
75+
with contextlib.suppress(sd.PortAudioError):
76+
hostapi_list.current(sd.default.hostapi)
77+
hostapi_list.event_generate('<<ComboboxSelected>>')
78+
79+
80+
class RecGui(tk.Tk):
81+
82+
stream = None
83+
84+
def __init__(self):
85+
tk.Tk.__init__(self)
86+
87+
self.title('Recording GUI')
88+
89+
padding = 10
90+
91+
f = ttk.Frame()
92+
93+
self.rec_button = ttk.Button(f)
94+
self.rec_button.pack(side='left', padx=padding, pady=padding)
95+
96+
self.settings_button = ttk.Button(
97+
f, text='settings', command=self.on_settings)
98+
self.settings_button.pack(side='left', padx=padding, pady=padding)
99+
100+
f.pack(expand=True, padx=padding, pady=padding)
101+
102+
self.file_label = ttk.Label(text='<file name>')
103+
self.file_label.pack(anchor='w')
104+
105+
self.input_overflows = 0
106+
self.status_label = ttk.Label()
107+
self.status_label.pack(anchor='w')
108+
109+
self.meter = ttk.Progressbar()
110+
self.meter['orient'] = 'horizontal'
111+
self.meter['mode'] = 'determinate'
112+
self.meter['maximum'] = 1.0
113+
self.meter.pack(fill='x')
114+
115+
# We try to open a stream with default settings first, if that doesn't
116+
# work, the user can manually change the device(s)
117+
self.create_stream()
118+
119+
self.recording = self.previously_recording = False
120+
self.audio_q = queue.Queue()
121+
self.peak = 0
122+
self.metering_q = queue.Queue(maxsize=1)
123+
124+
self.protocol('WM_DELETE_WINDOW', self.close_window)
125+
self.init_buttons()
126+
self.update_gui()
127+
128+
def create_stream(self, device=None):
129+
if self.stream is not None:
130+
self.stream.close()
131+
self.stream = sd.InputStream(
132+
device=device, channels=1, callback=self.audio_callback)
133+
self.stream.start()
134+
135+
def audio_callback(self, indata, frames, time, status):
136+
"""This is called (from a separate thread) for each audio block."""
137+
if status.input_overflow:
138+
# NB: This increment operation is not atomic, but this doesn't
139+
# matter since no other thread is writing to the attribute.
140+
self.input_overflows += 1
141+
# NB: self.recording is accessed from different threads.
142+
# This is safe because here we are only accessing it once (with a
143+
# single bytecode instruction).
144+
if self.recording:
145+
self.audio_q.put(indata.copy())
146+
self.previously_recording = True
147+
else:
148+
if self.previously_recording:
149+
self.audio_q.put(None)
150+
self.previously_recording = False
151+
152+
self.peak = max(self.peak, np.max(np.abs(indata)))
153+
try:
154+
self.metering_q.put_nowait(self.peak)
155+
except queue.Full:
156+
pass
157+
else:
158+
self.peak = 0
159+
160+
def on_rec(self):
161+
self.settings_button['state'] = 'disabled'
162+
self.recording = True
163+
164+
filename = tempfile.mktemp(
165+
prefix='delme_rec_gui_', suffix='.wav', dir='')
166+
167+
if self.audio_q.qsize() != 0:
168+
print('WARNING: Queue not empty!')
169+
self.thread = threading.Thread(
170+
target=file_writing_thread,
171+
kwargs=dict(
172+
file=filename,
173+
mode='x',
174+
samplerate=int(self.stream.samplerate),
175+
channels=self.stream.channels,
176+
q=self.audio_q,
177+
),
178+
)
179+
self.thread.start()
180+
181+
# NB: File creation might fail! For brevity, we don't check for this.
182+
183+
self.rec_button['text'] = 'stop'
184+
self.rec_button['command'] = self.on_stop
185+
self.rec_button['state'] = 'normal'
186+
self.file_label['text'] = filename
187+
188+
def on_stop(self, *args):
189+
self.rec_button['state'] = 'disabled'
190+
self.recording = False
191+
self.wait_for_thread()
192+
193+
def wait_for_thread(self):
194+
# NB: Waiting time could be calculated based on stream.latency
195+
self.after(10, self._wait_for_thread)
196+
197+
def _wait_for_thread(self):
198+
if self.thread.is_alive():
199+
self.wait_for_thread()
200+
return
201+
self.thread.join()
202+
self.init_buttons()
203+
204+
def on_settings(self, *args):
205+
w = SettingsWindow(self, 'Settings')
206+
self.create_stream(device=w.result)
207+
208+
def init_buttons(self):
209+
self.rec_button['text'] = 'record'
210+
self.rec_button['command'] = self.on_rec
211+
if self.stream:
212+
self.rec_button['state'] = 'normal'
213+
self.settings_button['state'] = 'normal'
214+
215+
def update_gui(self):
216+
self.status_label['text'] = 'input overflows: {}'.format(
217+
self.input_overflows)
218+
try:
219+
peak = self.metering_q.get_nowait()
220+
except queue.Empty:
221+
pass
222+
else:
223+
self.meter['value'] = peak
224+
self.after(100, self.update_gui)
225+
226+
def close_window(self):
227+
if self.recording:
228+
self.on_stop()
229+
self.destroy()
230+
231+
232+
def main():
233+
app = RecGui()
234+
app.mainloop()
235+
236+
237+
if __name__ == '__main__':
238+
main()

0 commit comments

Comments
 (0)