-
Notifications
You must be signed in to change notification settings - Fork 75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Sonify plugin updates #3269
Sonify plugin updates #3269
Changes from all commits
6049d9a
627a34f
9e98b32
e6d52b4
0080b2f
4b01304
505f73f
89d6fff
5c67736
d8937c5
75b7ded
9d6844d
1d1417c
6421ee5
352eff9
a7d501e
ceafbb0
a4811e4
2a270ec
13f37a8
4a1d628
a05937d
0f0eb87
a56b26e
18634ea
cc51183
5d308ea
c6de013
eaae60f
9682321
630be77
51a4154
cd1963e
de530d9
f3e65df
15a8671
a5ef389
a66beef
b4b64e0
99d04e8
9747650
b1d5806
2d0c5a4
ae98601
91ed211
30ddca8
1ab90f6
434a42a
bf4fa3f
6249a8c
14c1602
2dc155b
89dfcd2
8172cdd
b552edc
30e8ad2
4494ba5
92982bd
17fd0e5
dc478ce
e25022f
cc81d63
fdef225
41b51f6
a12b687
908e0af
4f34615
708024c
3081c4d
393031b
853265a
65afcbc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
import numpy as np | ||
from contextlib import contextmanager | ||
import sys | ||
import os | ||
import time | ||
|
||
try: | ||
from strauss.sonification import Sonification | ||
from strauss.sources import Events | ||
from strauss.score import Score | ||
from strauss.generator import Spectralizer | ||
except ImportError: | ||
pass | ||
|
||
# smallest fraction of the max audio amplitude that can be represented by a 16-bit signed integer | ||
MINVOL = 1/(2**15 - 1) | ||
javerbukh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
|
||
@contextmanager | ||
def suppress_stderr(): | ||
with open(os.devnull, "w") as devnull: | ||
old_stderr = sys.stderr | ||
sys.stderr = devnull | ||
try: | ||
yield | ||
finally: | ||
sys.stderr = old_stderr | ||
|
||
|
||
def sonify_spectrum(spec, duration, overlap=0.05, system='mono', srate=44100, fmin=40, fmax=1300, | ||
eln=False): | ||
notes = [["A2"]] | ||
score = Score(notes, duration) | ||
# set up spectralizer generator | ||
generator = Spectralizer(samprate=srate) | ||
|
||
# Lets pick the mapping frequency range for the spectrum... | ||
generator.modify_preset({'min_freq': fmin, 'max_freq': fmax, | ||
'fit_spec_multiples': False, | ||
'interpolation_type': 'preserve_power', | ||
'equal_loudness_normalisation': eln}) | ||
|
||
data = {'spectrum': [spec], 'pitch': [1]} | ||
|
||
# again, use maximal range for the mapped parameters | ||
lims = {'spectrum': ('0', '100')} | ||
|
||
# set up source | ||
sources = Events(data.keys()) | ||
sources.fromdict(data) | ||
sources.apply_mapping_functions(map_lims=lims) | ||
|
||
# render and play sonification! | ||
soni = Sonification(score, sources, generator, system, samprate=srate) | ||
soni.render() | ||
soni._make_seamless(overlap) | ||
|
||
return soni.loop_channels['0'].values | ||
|
||
|
||
class CubeListenerData: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same as above re test coverage |
||
def __init__(self, cube, wlens, samplerate=44100, duration=1, overlap=0.05, buffsize=1024, | ||
bdepth=16, wl_bounds=None, wl_unit=None, audfrqmin=50, audfrqmax=1500, | ||
eln=False, vol=None): | ||
self.siglen = int(samplerate*(duration-overlap)) | ||
self.cube = cube | ||
self.dur = duration | ||
self.bdepth = bdepth | ||
self.srate = samplerate | ||
self.maxval = pow(2, bdepth-1) - 1 | ||
self.fadedx = 0 | ||
|
||
if vol is None: | ||
self.atten_level = 1 | ||
else: | ||
self.atten_level = int(np.clip((vol/100)**2, MINVOL, 1)) | ||
|
||
self.wl_bounds = wl_bounds | ||
self.wl_unit = wl_unit | ||
self.wlens = wlens | ||
|
||
# control fades | ||
fade = np.linspace(0, 1, buffsize+1) | ||
self.ifade = fade[:-1] | ||
self.ofade = fade[::-1][:-1] | ||
|
||
# mapping frequency limits in Hz | ||
self.audfrqmin = audfrqmin | ||
self.audfrqmax = audfrqmax | ||
|
||
# do we normalise for equal loudness? | ||
self.eln = eln | ||
|
||
self.idx1 = 0 | ||
self.idx2 = 0 | ||
self.cbuff = False | ||
self.cursig = np.zeros(self.siglen, dtype='int16') | ||
self.newsig = np.zeros(self.siglen, dtype='int16') | ||
|
||
if self.cursig.nbytes * pow(1024, -3) > 2: | ||
raise Exception("Cube projected to be > 2Gb!") | ||
|
||
self.sigcube = np.zeros((*self.cube.shape[:2], self.siglen), dtype='int16') | ||
|
||
def set_wl_bounds(self, w1, w2): | ||
""" | ||
set the wavelength bounds for indexing spectra | ||
""" | ||
wsrt = np.sort([w1, w2]) | ||
self.wl_bounds = tuple(wsrt) | ||
|
||
def sonify_cube(self): | ||
""" | ||
Iterate through the cube, convert each spectrum to a signal, and store | ||
in class attributes | ||
""" | ||
lo2hi = self.wlens.argsort()[::-1] | ||
|
||
t0 = time.time() | ||
for i in range(self.cube.shape[0]): | ||
for j in range(self.cube.shape[1]): | ||
with suppress_stderr(): | ||
if self.cube[i, j, lo2hi].any(): | ||
sig = sonify_spectrum(self.cube[i, j, lo2hi], self.dur, | ||
srate=self.srate, | ||
fmin=self.audfrqmin, | ||
fmax=self.audfrqmax, | ||
eln=self.eln) | ||
sig = (sig*self.maxval).astype('int16') | ||
self.sigcube[i, j, :] = sig | ||
else: | ||
continue | ||
self.cursig[:] = self.sigcube[self.idx1, self.idx2, :] | ||
self.newsig[:] = self.cursig[:] | ||
t1 = time.time() | ||
print(f"Took {t1-t0}s to process {self.cube.shape[0]*self.cube.shape[1]} spaxels") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this print statement a temporary way to inform the user or for debugging? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it was originally for debugging but now it might be useful for logging performance. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Printing out stuff is also a performance hit. I suggest you move this to debug log message if you really want to keep it. Otherwise it will spam the terminal whether we want it or not. |
||
|
||
def player_callback(self, outdata, frames, time, status): | ||
cur = self.cursig | ||
new = self.newsig | ||
sdx = int(time.outputBufferDacTime*self.srate) | ||
dxs = np.arange(sdx, sdx+frames).astype(int) % self.sigcube.shape[-1] | ||
if self.cbuff: | ||
outdata[:, 0] = (cur[dxs] * self.ofade).astype('int16') | ||
outdata[:, 0] += (new[dxs] * self.ifade).astype('int16') | ||
self.cursig[:] = self.newsig[:] | ||
self.cbuff = False | ||
else: | ||
outdata[:, 0] = self.cursig[dxs] | ||
outdata[:, 0] //= self.atten_level | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
from traitlets import Bool, List, Unicode, observe | ||
import astropy.units as u | ||
|
||
from jdaviz.core.custom_traitlets import IntHandleEmpty, FloatHandleEmpty | ||
from jdaviz.core.registries import tray_registry | ||
from jdaviz.core.template_mixin import (PluginTemplateMixin, DatasetSelectMixin, | ||
SpectralSubsetSelectMixin, with_spinner) | ||
from jdaviz.core.user_api import PluginUserApi | ||
|
||
|
||
__all__ = ['SonifyData'] | ||
|
||
try: | ||
import strauss # noqa | ||
import sounddevice as sd | ||
except ImportError: | ||
class Empty: | ||
pass | ||
sd = Empty() | ||
sd.default = Empty() | ||
sd.default.device = [-1, -1] | ||
_has_strauss = False | ||
else: | ||
_has_strauss = True | ||
|
||
|
||
@tray_registry('cubeviz-sonify-data', label="Sonify Data", | ||
viewer_requirements=['spectrum', 'image']) | ||
class SonifyData(PluginTemplateMixin, DatasetSelectMixin, SpectralSubsetSelectMixin): | ||
""" | ||
See the :ref:`Sonify Data Plugin Documentation <cubeviz-sonify-data>` for more details. | ||
|
||
Only the following attributes and methods are available through the | ||
:ref:`public plugin API <plugin-apis>`: | ||
|
||
* :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.show` | ||
* :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.open_in_tray` | ||
* :meth:`~jdaviz.core.template_mixin.PluginTemplateMixin.close_in_tray` | ||
kecnry marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
template_file = __file__, "sonify_data.vue" | ||
|
||
sample_rate = IntHandleEmpty(44100).tag(sync=True) | ||
buffer_size = IntHandleEmpty(2048).tag(sync=True) | ||
assidx = FloatHandleEmpty(2.5).tag(sync=True) | ||
ssvidx = FloatHandleEmpty(0.65).tag(sync=True) | ||
eln = Bool(False).tag(sync=True) | ||
audfrqmin = FloatHandleEmpty(50).tag(sync=True) | ||
audfrqmax = FloatHandleEmpty(1500).tag(sync=True) | ||
pccut = IntHandleEmpty(20).tag(sync=True) | ||
volume = IntHandleEmpty(100).tag(sync=True) | ||
stream_active = Bool(True).tag(sync=True) | ||
has_strauss = Bool(_has_strauss).tag(sync=True) | ||
|
||
# TODO: can we referesh the list, so sounddevices are up-to-date when dropdown clicked? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is there a follow-up for this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I will create one and link it here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
sound_devices_items = List().tag(sync=True) | ||
sound_devices_selected = Unicode('').tag(sync=True) | ||
|
||
def __init__(self, *args, **kwargs): | ||
super().__init__(*args, **kwargs) | ||
self._plugin_description = 'Sonify a data cube' | ||
self.docs_description = 'Sonify a data cube using the Strauss package.' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is there any in-UI instructions on how to "play" the cube once generated. Maybe either here or in a message after pressing sonify, we should point to the tool with some instructions. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have information in the documentation (linked in the UI) for how to listen to the cube after pressing the sonify button. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can see what users say too - but I suspect the connection with the spaxel tool might not be obvious and an alert below the button to generate the cube would help (but can be follow-up if you want). |
||
if not self.has_strauss or sd.default.device[1] < 0: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @pllim This line here will cause the plugin do be disabled if there is not a valid sound output device. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Then why is CI failing? Something is running when it is not supposed to? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I added a check in |
||
self.disabled_msg = ('To use Sonify Data, install strauss and restart Jdaviz. You ' | ||
'can do this by running pip install strauss in the command' | ||
' line and then launching Jdaviz. Currently, this plugin only' | ||
' works on devices with valid sound output.') | ||
|
||
else: | ||
devices, indexes = self.build_device_lists() | ||
self.sound_device_indexes = dict(zip(devices, indexes)) | ||
self.sound_devices_items = devices | ||
self.sound_devices_selected = dict(zip(indexes, devices))[sd.default.device[1]] | ||
|
||
# TODO: Remove hardcoded range and flux viewer | ||
self.spec_viewer = self.app.get_viewer('spectrum-viewer') | ||
self.flux_viewer = self.app.get_viewer('flux-viewer') | ||
|
||
@property | ||
def user_api(self): | ||
expose = [] | ||
return PluginUserApi(self, expose) | ||
|
||
@with_spinner() | ||
def vue_sonify_cube(self, *args): | ||
if self.disabled_msg: | ||
raise ValueError('Unable to sonify cube') | ||
|
||
# Get index of selected device | ||
selected_device_index = self.sound_device_indexes[self.sound_devices_selected] | ||
|
||
# Apply spectral subset bounds | ||
if self.spectral_subset_selected is not self.spectral_subset.default_text: | ||
display_unit = self.spec_viewer.state.x_display_unit | ||
min_wavelength = self.spectral_subset.selected_obj.lower.to_value(u.Unit(display_unit)) | ||
max_wavelength = self.spectral_subset.selected_obj.upper.to_value(u.Unit(display_unit)) | ||
self.flux_viewer.update_listener_wls(min_wavelength, max_wavelength, display_unit) | ||
|
||
self.flux_viewer.get_sonified_cube(self.sample_rate, self.buffer_size, | ||
selected_device_index, self.assidx, self.ssvidx, | ||
self.pccut, self.audfrqmin, | ||
self.audfrqmax, self.eln) | ||
|
||
# Automatically select spectrum-at-spaxel tool | ||
spec_at_spaxel_tool = self.flux_viewer.toolbar.tools['jdaviz:spectrumperspaxel'] | ||
self.flux_viewer.toolbar.active_tool = spec_at_spaxel_tool | ||
Comment on lines
+103
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can't decide if this is convenient or could be confusing, it isn't a pattern we currently use anywhere although I think we have discussed the ability to control tool selection from plugins. Maybe @Jenneh will have thoughts (whether the "spectrum at spaxel" tool in the flux cube viewer should automatically activate after sonification is complete, perhaps deactivating any other tool the user had enabled previously). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree that it is not expected behavior but early user testing informed us that we needed to automatically have the sound play after the user presses sonify data and the audified cube is loaded. I can create a follow-up ticket to decide exactly how we want to do this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok, we can do this for now, but let's please revisit. Maybe (eventually) a message with a button in the plugin itself to first activate the tool would be ideal to help teach how to toggle it later - we had considered this for other plugins as well but don't yet have the infrastructure to do that. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See also #3269 (comment) - we could switch from the tool to having it dependent on the "active" state of the plugin. That might then avoid user-confusion and the need to instruct altogether and would be consistent with other plugin-owned mouseover events. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This may become a moot point once we have the sonified cube as its own layer in the viewer. We will still need to instruct the user how to get the sound to turn on/off (depending on the default behavior) but by that point it will be out of the spectrum-at-spaxel tool. Related tickets #3329 #3330 #3331 |
||
|
||
def vue_start_stop_stream(self, *args): | ||
self.stream_active = not self.stream_active | ||
self.flux_viewer.stream_active = not self.flux_viewer.stream_active | ||
|
||
@observe('volume') | ||
def update_volume_level(self, event): | ||
self.flux_viewer.update_volume_level(event['new']) | ||
|
||
@observe('sound_devices_selected') | ||
def update_sound_device(self, event): | ||
if event['new'] != event['old']: | ||
didx = dict(zip(*self.build_device_lists()))[event['new']] | ||
self.flux_viewer.update_sound_device(didx) | ||
|
||
def build_device_lists(self): | ||
# dedicated function to build the current *output* | ||
# device and index lists | ||
devices = [] | ||
device_indexes = [] | ||
for index, device in enumerate(sd.query_devices()): | ||
if device['max_output_channels'] > 0 and device['name'] not in devices: | ||
devices.append(device['name']) | ||
device_indexes.append(index) | ||
return devices, device_indexes | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should add a note to users on how to install the optional dependencies and maybe even mention the non-Python dependency of libportaudio2 (is that a Linux only thing?).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in STRAUSS we throw a custom error if you try and use the PortAudio functionality and it's not there. This seems to be a non mac / windows problem
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that depends if users need to install that manually after running
pip install .[strauss]
. I am able to get all dependencies installed with that command on windows.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At the very least maybe repeat the
[strauss]
command here? I see you already have it in the plugin doc on.vue
.