Skip to content

Commit 64872df

Browse files
tomstollpre-commit-ci[bot]larsoner
authored
Add sounddevice backend for gapless audio playback. (#494)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Eric Larson <larson.eric.d@gmail.com>
1 parent e9174d9 commit 64872df

10 files changed

Lines changed: 382 additions & 123 deletions

File tree

.github/workflows/tests.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,15 @@ jobs:
5151
set -xeo pipefail
5252
if [[ "${{ runner.os }}" == "Windows" ]]; then
5353
echo "Setting env vars for Windows"
54-
echo "AZURE_CI_WINDOWS=true" >> $GITHUB_ENV
55-
echo "SOUND_CARD_BACKEND=rtmixer" >> $GITHUB_ENV
56-
echo "SOUND_CARD_NAME=Speakers" >> $GITHUB_ENV
57-
echo "SOUND_CARD_FS=48000" >> $GITHUB_ENV
58-
echo "SOUND_CARD_API=Windows WDM-KS" >> $GITHUB_ENV
54+
echo "AZURE_CI_WINDOWS=true" | tee -a $GITHUB_ENV
55+
echo "SOUND_CARD_BACKEND=rtmixer" | tee -a $GITHUB_ENV
56+
echo "SOUND_CARD_NAME=Sound Mapper" | tee -a $GITHUB_ENV
57+
echo "SOUND_CARD_FS=48000" | tee -a $GITHUB_ENV
58+
echo "SOUND_CARD_API=MME" | tee -a $GITHUB_ENV
5959
elif [[ "${{ runner.os }}" == "Linux" ]]; then
6060
echo "Setting env vars for Linux"
61-
echo "_EXPYFUN_SILENT=true" >> $GITHUB_ENV
62-
echo "SOUND_CARD_BACKEND=pyglet" >> $GITHUB_ENV
61+
echo "_EXPYFUN_SILENT=true" | tee -a $GITHUB_ENV
62+
echo "SOUND_CARD_BACKEND=pyglet" | tee -a $GITHUB_ENV
6363
elif [[ "${{ runner.os }}" == "macOS" ]]; then
6464
echo "Setting env vars for macOS"
6565
fi

expyfun/_experiment_controller.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
ZeroClock,
2828
_check_pyglet_version,
2929
_fix_audio_dims,
30-
_get_args,
3130
_get_display,
3231
_sanitize,
3332
_TempDir,
@@ -143,6 +142,17 @@ class ExperimentController:
143142
The trigger duration to use (sec). Must be 0.01 for TDT.
144143
joystick : bool
145144
Whether or not to enable joystick control.
145+
gapless : bool
146+
Whether or not to use sounddevice, allowing gapless playback. Setting
147+
this to True (default False) requires:
148+
149+
1. AUDIO_CONTROLLER must be ``"sound_card"``
150+
2. SOUND_CARD_BACKEND must be ``"sounddevice"``
151+
3. SOUND_CARD_TRIGGER_ID_AFTER_ONSET must be set to ``True``.
152+
4. On Windows, SOUND_CARD_API must be ``"ASIO"``, ``"MME"``, or ``"WASAPI"``
153+
154+
Note that for gapless playback, you should not use ``ec.wait_secs()`` or
155+
``ec.stop()`` in the experiment loop.
146156
verbose : bool, str, int, or None
147157
If not None, override default verbose level (see expyfun.verbose).
148158
@@ -167,6 +177,7 @@ def __init__(
167177
stim_fs=24414,
168178
stim_db=65,
169179
noise_db=45,
180+
*,
170181
noise_array=None,
171182
output_dir="data",
172183
window_size=None,
@@ -184,6 +195,7 @@ def __init__(
184195
n_channels=2,
185196
trigger_duration=0.01,
186197
joystick=False,
198+
gapless=False,
187199
verbose=None,
188200
):
189201
from . import __version__
@@ -209,6 +221,7 @@ def __init__(
209221
self._data_file = None
210222
self._clock = ZeroClock()
211223
self._master_clock = self._clock.get_time
224+
self._gapless = gapless
212225

213226
# put anything that could fail in this block to ensure proper cleanup!
214227
try:
@@ -249,7 +262,8 @@ def __init__(
249262
# dictionary for experiment metadata
250263
self._exp_info = OrderedDict()
251264

252-
for name in _get_args(self.__init__):
265+
spec = inspect.getfullargspec(self.__init__)
266+
for name in spec.args[1:] + spec.kwonlyargs:
253267
if name != "self":
254268
self._exp_info[name] = locals()[name]
255269
self._exp_info["date"] = date_str()
@@ -368,6 +382,12 @@ def __init__(
368382
"type %s" % (type(audio_controller),)
369383
)
370384
audio_type = audio_controller["TYPE"].lower()
385+
if self._gapless:
386+
if audio_type != "sound_card":
387+
raise RuntimeError(
388+
'audio_controller must be "sound_card" for gapless '
389+
'playback, got "%s"' % audio_type
390+
)
371391

372392
#
373393
# parse response device
@@ -1867,7 +1887,7 @@ def load_buffer(self, samples):
18671887
ExperimentController.start_stimulus
18681888
ExperimentController.stop
18691889
"""
1870-
if self._playing:
1890+
if self._playing and not self._gapless:
18711891
raise RuntimeError(
18721892
"Previous audio must be stopped before loading the buffer"
18731893
)
@@ -1899,7 +1919,7 @@ def play(self):
18991919

19001920
def _play(self):
19011921
"""Play the audio buffer."""
1902-
if self._playing:
1922+
if self._playing and not self._gapless:
19031923
raise RuntimeError("Previous audio must be stopped before playing")
19041924
self._ac.play()
19051925
logger.debug("Expyfun: started audio")

expyfun/_sound_controllers/_rtmixer.py

Lines changed: 11 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,12 @@
77
import sys
88

99
import numpy as np
10-
import sounddevice
1110
from rtmixer import Mixer, RingBuffer
1211

1312
from .._utils import get_config, logger
13+
from ._sounddevice import _find_device
1414

1515
_PRIORITY = 100
16-
_DEFAULT_NAME = None
1716

1817

1918
# only initialize each mixer once and reuse it until this gets garbage
@@ -23,7 +22,7 @@
2322
class _MixerRegistry(dict):
2423
def __del__(self):
2524
for mixer in self.values():
26-
print(f"Closing {mixer}")
25+
logger.debug(f"Closing {mixer}")
2726
mixer.abort()
2827
mixer.close()
2928
self.clear()
@@ -52,56 +51,12 @@ def _get_mixer(self, fs, n_channels, api, name, api_options):
5251

5352

5453
def _init_mixer(fs, n_channels, api, name, api_options=None):
55-
devices = sounddevice.query_devices()
56-
if len(devices) == 0:
57-
raise OSError("No sound devices found!")
58-
apis = sounddevice.query_hostapis()
59-
valid_apis = []
60-
for ai, this_api in enumerate(apis):
61-
if this_api["name"] == api:
62-
api = this_api
63-
break
64-
else:
65-
valid_apis.append(this_api["name"])
66-
else:
67-
m = 'Could not find host API %s. Valid choices are "%s"'
68-
raise RuntimeError(m % (api, ", ".join(valid_apis)))
69-
del this_api
70-
71-
# Name
72-
if name is None:
73-
name = get_config("SOUND_CARD_NAME", None)
74-
if name is None:
75-
global _DEFAULT_NAME
76-
if _DEFAULT_NAME is None:
77-
di = api["default_output_device"]
78-
_DEFAULT_NAME = devices[di]["name"]
79-
logger.exp("Selected default sound device: %r" % (_DEFAULT_NAME,))
80-
name = _DEFAULT_NAME
81-
possible = list()
82-
for di, device in enumerate(devices):
83-
if device["hostapi"] == ai:
84-
possible.append(device["name"])
85-
if name in device["name"]:
86-
break
87-
else:
88-
raise RuntimeError(
89-
"Could not find device on API %r with name "
90-
"containing %r, found:\n%s" % (api["name"], name, "\n".join(possible))
91-
)
92-
param_str = "sound card %r (devices[%d]) via %r" % (device["name"], di, api["name"])
93-
extra_settings = None
94-
if api_options is not None:
95-
if api["name"] == "Windows WASAPI":
96-
# exclusive mode is needed for zero jitter on Windows in testing
97-
extra_settings = sounddevice.WasapiSettings(**api_options)
98-
else:
99-
raise ValueError(
100-
'api_options only supported for "Windows WASAPI" backend, '
101-
"using %s backend got api_options=%s" % (api["name"], api_options)
102-
)
103-
param_str += " with options %s" % (api_options,)
104-
param_str += ", %d channels" % (n_channels,)
54+
device, param_str, extra_settings, all_devices = _find_device(
55+
n_channels,
56+
api,
57+
name,
58+
api_options=api_options,
59+
)
10560
if fs is not None:
10661
param_str += " @ %d Hz" % (fs,)
10762
try:
@@ -110,11 +65,11 @@ def _init_mixer(fs, n_channels, api, name, api_options=None):
11065
latency="low",
11166
channels=n_channels,
11267
dither_off=True,
113-
device=di,
68+
device=device["index"],
11469
extra_settings=extra_settings,
11570
)
116-
except Exception as exp:
117-
raise RuntimeError(f"Could not set up {param_str}:\n{exp}") from None
71+
except Exception:
72+
raise RuntimeError(f"Could not set up {param_str}:\n\n{all_devices}")
11873
assert mixer.channels == n_channels
11974
if fs is None:
12075
param_str += " @ %d Hz" % (mixer.samplerate,)

expyfun/_sound_controllers/_sound_controller.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class SoundCardController:
6565
Params should contain string values:
6666
6767
- 'SOUND_CARD_BACKEND' : str
68-
The backend to use. Can be 'auto' (default), 'rtmixer', 'pyglet'.
68+
The backend to use. Can be 'auto' (default), 'rtmixer', 'pyglet', 'sounddevice'.
6969
- 'SOUND_CARD_API' : str
7070
The API to use for the sound card.
7171
See :func:`sounddevice.query_hostapis`.
@@ -162,6 +162,30 @@ def __init__(self, params, stim_fs, n_channels=2, trigger_duration=0.01, ec=None
162162
key: params["SOUND_CARD_" + key.upper()]
163163
for key in ("fs", "api", "name", "fixed_delay", "api_options")
164164
}
165+
if self.backend_name == "sounddevice": # use this or next line
166+
# ensure id triggers are after onset for gapless playback
167+
if not self.ec._gapless:
168+
raise NotImplementedError(
169+
"Currently, only gapless=True is allowed for sounddevice backend"
170+
)
171+
else:
172+
if not self._id_after_onset:
173+
raise ValueError(
174+
"SOUND_CARD_TRIGGER_ID_AFTER_ONSET must be True for"
175+
" gapless playback."
176+
)
177+
# make sure the API is one that works with sounddevice
178+
allowed_apis = ["MME", "Windows WASAPI", "ASIO", None]
179+
if os.name == "nt" and (params["SOUND_CARD_API"] not in allowed_apis):
180+
raise ValueError(
181+
f"SOUND_CARD_API must be one of {allowed_apis[:-1]} for gapless "
182+
f"playback, got {params['SOUND_CARD_API']}."
183+
)
184+
elif self.ec._gapless:
185+
raise RuntimeError(
186+
'SOUND_CARD_BACKEND must be "sounddevice" for gapless '
187+
f"playback, got {self.backend_name!r}"
188+
)
165189
temp_sound = np.zeros((self._n_channels_tot, 1000))
166190
temp_sound = self.backend.SoundPlayer(temp_sound, **self._kwargs)
167191
self.fs = float(temp_sound.fs)
@@ -271,10 +295,11 @@ def load_buffer(self, samples):
271295
The sound samples.
272296
"""
273297
assert samples.ndim == 2
274-
self.stop(wait=False)
275-
if self.audio is not None:
276-
self.audio.delete()
277-
self.audio = None
298+
if not self.ec._gapless:
299+
self.stop(wait=False)
300+
if self.audio is not None:
301+
self.audio.delete()
302+
self.audio = None
278303
if self._n_channels_stim > 0:
279304
stim = self._make_digital_trigger([1] + self._extra_onset_triggers)
280305
stim_len = len(stim)
@@ -413,7 +438,7 @@ def stamp_triggers(
413438

414439
def play(self):
415440
"""Play."""
416-
assert not self.playing
441+
assert not self.playing or self.ec._gapless
417442
if self.audio is not None:
418443
self.audio.play()
419444
self.playing = True

0 commit comments

Comments
 (0)