-
Notifications
You must be signed in to change notification settings - Fork 74
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
base: main
Are you sure you want to change the base?
Sonify plugin updates #3269
Changes from 45 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
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 | ||
from tqdm import tqdm | ||
except ImportError: | ||
pass | ||
|
||
MINVOL = 1/(2**15 - 1) | ||
|
||
|
||
@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 audify_spectrum(spec, duration, overlap=0.05, system='mono', srate=44100, fmin=40, fmax=1300, | ||
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 whole function is never reached by tests - is it possible to add coverage (or if out of scope, can we create a follow-up)? 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. on closer look, it seems that the existing tests added in this PR are probably never actually running on CI because of dependencies. Can we add strauss to at least one of the runners so they do run? 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. Can I add the Strauss package 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. let's discuss - it might be time to make a convention for this and do the same thing for footprints, Roman, and strauss. 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. any resolution to the test coverage situation? |
||
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 audify_cube(self): | ||
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. Given 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. Yeah I think that is what #3330 is hoping to fix by moving some/all of the |
||
""" | ||
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 tqdm(range(self.cube.shape[0])): | ||
for j in range(self.cube.shape[1]): | ||
Comment on lines
+120
to
+121
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. Any time I see nested for loops it makes me twitch - I forget how long this takes, but I would advocate for a follow-up ticket 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. Good call, tracking that issue at #3339 . |
||
with suppress_stderr(): | ||
if self.cube[i, j, lo2hi].any(): | ||
sig = audify_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. |
||
|
||
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,121 @@ | ||
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: | ||
_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: | ||
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.') | ||
|
||
if self.has_strauss: | ||
javerbukh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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): | ||
# 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
+94
to
+96
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.
is it possible for any of these imports to fail but there still be a call to any of the logic below (either manual API calls or if none of the imports in the plugin itself fail and so
_has_strauss = True
buttqdm
failed to import, for example)?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.
None of the methods are public API and
tqdm
is a dependency of Strauss, but I can see that being an issue down the road. Is it alright for this to be a follow-up?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 wonder if we can either put the import
tqdm
in theelse
or import_has_strauss
from the plugin. But yes, probably can be a follow-up.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 if we decide to move most of the
cube_listener.py
code to the sonify plugin then we can include this effort in #3330 .