Skip to content

Commit d0ee2db

Browse files
authored
Sonify in any viewer (spacetelescope#3690)
* Moving code from viewer to sonify plugin More work moving sonification code to plugin Finish moving get_sonified_cube to plugin Moving more things to plugin from viewer Trying to figure out how much more should go in the plugin Missed this one Only do this when needed No errors but sound isn't playing * Fix cherry pick * Debugging * Update sonified cube based on viewer * Codestyle * Changelog * Remove defunct code * Fix existing test and add new checks for adding to second viewer * Codestyle * Restore removal of callback before adding * Allow the user to add the sonified data to any viewer right off the bat. Remove unused viewer select disable props
1 parent f1c8807 commit d0ee2db

7 files changed

Lines changed: 245 additions & 218 deletions

File tree

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ Cubeviz
4040

4141
- Add sonified layer for each cube created by the Sonify Data plugin. [#3430, #3660]
4242

43+
- Sonified data can now be added to any image viewer after initial sonification. [#3690]
44+
4345
- Renamed ``Spectral Extraction`` plugin to ``3D Spectral Extraction``. [#3691]
4446

4547
Imviz

jdaviz/components/plugin_add_results.vue

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
:api_hint="add_results_api_hint && add_results_api_hint+'.viewer ='"
3333
:api_hints_enabled="api_hints_enabled && add_results_api_hint"
3434
:hint="add_to_viewer_hint ? add_to_viewer_hint : 'Plot results in the specified viewer. Data entry will be available in the data dropdown for all applicable viewers.'"
35-
:add_to_viewer_disabled="add_to_viewer_disabled"
3635
></plugin-viewer-select>
3736
</div>
3837

@@ -95,8 +94,7 @@
9594
props: ['add_results_api_hint',
9695
'label', 'label_default', 'label_auto', 'label_invalid_msg', 'label_overwrite', 'label_label', 'label_hint',
9796
'add_to_viewer_items', 'add_to_viewer_selected', 'add_to_viewer_hint', 'auto_update_result',
98-
'action_disabled', 'action_spinner', 'action_label', 'action_api_hint', 'action_tooltip', 'api_hints_enabled',
99-
'add_to_viewer_disabled'],
97+
'action_disabled', 'action_spinner', 'action_label', 'action_api_hint', 'action_tooltip', 'api_hints_enabled'],
10098
computed: {
10199
actionButtonText() {
102100
if (this.api_hints_enabled && this.action_api_hint) {

jdaviz/components/plugin_viewer_select.vue

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22
<div>
3-
<v-row v-if="show_multiselect_toggle && api_hints_enabled && api_hint_multiselect">
3+
<v-row v-if="show_multiselect_toggle && api_hints_enabled && api_hint_multiselect">
44
<span :class="api_hints_enabled && api_hint_multiselect ? 'api-hint' : null">
55
{{ api_hint_multiselect }} {{ multiselect ? 'True' : 'False' }}
66
</span>
@@ -31,7 +31,6 @@
3131
:chips="multiselect && !api_hints_enabled"
3232
item-text="label"
3333
item-value="label"
34-
:disabled="add_to_viewer_disabled"
3534
persistent-hint
3635
>
3736
<template v-slot:selection="{ item, index }">
@@ -91,7 +90,7 @@
9190
module.exports = {
9291
props: ['items', 'selected', 'label', 'hint', 'rules', 'show_if_single_entry', 'multiselect',
9392
'show_multiselect_toggle', 'icon_checktoradial', 'icon_radialtocheck',
94-
'api_hint', 'api_hint_multiselect', 'api_hints_enabled', 'add_to_viewer_disabled']
93+
'api_hint', 'api_hint_multiselect', 'api_hints_enabled']
9594
};
9695
</script>
9796

jdaviz/configs/cubeviz/plugins/sonify_data/sonify_data.py

Lines changed: 183 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import time
2-
from traitlets import Bool, List, Unicode, observe
2+
3+
from astropy.nddata import CCDData
34
import astropy.units as u
5+
from astropy.wcs import WCS
6+
import numpy as np
7+
from scipy.special import erf
8+
from traitlets import Bool, List, Unicode, observe
49

510
from jdaviz.core.custom_traitlets import IntHandleEmpty, FloatHandleEmpty
611
from jdaviz.core.registries import tray_registry
712
from jdaviz.core.template_mixin import (PluginTemplateMixin, DatasetSelectMixin,
813
SpectralSubsetSelectMixin, with_spinner,
914
AddResultsMixin)
1015
from jdaviz.core.user_api import PluginUserApi
11-
from jdaviz.core.events import SnackbarMessage
12-
16+
from jdaviz.core.events import SnackbarMessage, AddDataMessage
17+
from jdaviz.configs.cubeviz.plugins.cube_listener import CubeListenerData, INT_MAX
18+
from jdaviz.core.sonified_layers import SonifiedLayerState
1319

1420
__all__ = ['SonifyData']
1521

@@ -60,8 +66,6 @@ class SonifyData(PluginTemplateMixin, DatasetSelectMixin, SpectralSubsetSelectMi
6066
sound_devices_items = List().tag(sync=True)
6167
sound_devices_selected = Unicode('').tag(sync=True)
6268

63-
add_to_viewer_enabled = Bool(False).tag(sync=True)
64-
6569
def __init__(self, *args, **kwargs):
6670
super().__init__(*args, **kwargs)
6771

@@ -77,10 +81,23 @@ def __init__(self, *args, **kwargs):
7781
self.sound_device_indexes = None
7882
self.refresh_device_list()
7983

84+
self.add_results.viewer.add_filter('is_image_viewer')
8085
self.add_to_viewer_selected = 'flux-viewer'
86+
self.sonified_cube = None
87+
self.sonified_viewers = []
88+
self.sonification_wl_ranges = None
89+
self.sonification_wl_unit = None
90+
self.stream = None
91+
# Dictionary that contains keys with UUIDs for each
92+
# sonified data layer. The value of each key is another dictionary containing
93+
# coordinates as keys and arrays representing sounds as the value.
94+
self.data_lookup = {}
8195

8296
self._update_label_default(None)
8397

98+
self.hub.subscribe(self, AddDataMessage,
99+
handler=self._data_added_to_viewer)
100+
84101
@property
85102
def user_api(self):
86103
expose = ['sonify_cube']
@@ -94,6 +111,36 @@ def _update_label_default(self, event):
94111
# Modify default label to avoid vue error from re-using label
95112
self.results_label_default = self.app.return_unique_name('Sonified data', typ='data')
96113

114+
def _data_added_to_viewer(self, msg):
115+
# Keep track of the volume attribute for each layer.
116+
msg.viewer.sonified_layers_enabled = []
117+
for layer in msg.viewer.state.layers:
118+
if not isinstance(layer, SonifiedLayerState):
119+
continue
120+
121+
# Add viewer to sonified_viewers if it isn't there already
122+
if msg.viewer not in self.sonified_viewers:
123+
self.sonified_viewers.append(msg.viewer)
124+
125+
# Find layer, add volume check to dictionary and add callback to volume changing and
126+
# audible changing
127+
msg.viewer.layer_volume[layer.layer.label] = layer.volume
128+
msg.viewer.sonified_layers_enabled += ([layer.layer.label] if
129+
getattr(layer, 'audible', False) else []) # noqa
130+
131+
layer.remove_callback('volume', msg.viewer.recalculate_combined_sonified_grid)
132+
layer.remove_callback('audible', msg.viewer.recalculate_combined_sonified_grid)
133+
layer.add_callback('volume', msg.viewer.recalculate_combined_sonified_grid)
134+
layer.add_callback('audible', msg.viewer.recalculate_combined_sonified_grid)
135+
136+
def start_stream(self):
137+
if self.stream and not self.stream.closed:
138+
self.stream.start()
139+
140+
def stop_stream(self):
141+
if self.stream and not self.stream.closed:
142+
self.stream.stop()
143+
97144
def sonify_cube(self):
98145
"""
99146
Create a sonified grid in the flux viewer so that sound plays when mousing over the viewer.
@@ -112,30 +159,141 @@ def sonify_cube(self):
112159
display_unit = self.spectrum_viewer.state.x_display_unit
113160
min_wavelength = self.spectral_subset.selected_obj.lower.to_value(u.Unit(display_unit))
114161
max_wavelength = self.spectral_subset.selected_obj.upper.to_value(u.Unit(display_unit))
115-
self.flux_viewer.update_listener_wls((min_wavelength, max_wavelength), display_unit)
162+
self.sonification_wl_ranges = ([min_wavelength, max_wavelength],)
163+
self.sonification_wl_unit = display_unit
116164

117165
# Ensure the current spectral region bounds are up-to-date at render time
118166
self.update_wavelength_range(None)
119167

120168
previous_label = self.results_label
121169

122170
# generate the sonified cube
123-
sonified_cube = self.flux_viewer.get_sonified_cube(self.sample_rate, self.buffer_size,
124-
selected_device_index, self.assidx,
125-
self.ssvidx, self.pccut, self.audfrqmin,
126-
self.audfrqmax, self.eln,
127-
self.use_pccut, self.results_label)
171+
sonified_cube = self.get_sonified_cube(self.sample_rate, self.buffer_size,
172+
selected_device_index, self.assidx,
173+
self.ssvidx, self.pccut, self.audfrqmin,
174+
self.audfrqmax, self.eln,
175+
self.use_pccut, self.results_label)
128176
sonified_cube.meta['Sonified'] = True
129-
self.add_results.add_results_from_plugin(sonified_cube, replace=False)
130177

131-
self.flux_viewer.recalculate_combined_sonified_grid()
178+
# For now, this still initially adds the sonified data to flux-viewer
179+
self.add_results.add_results_from_plugin(sonified_cube, replace=False)
180+
self.add_results.viewer.selected_obj.recalculate_combined_sonified_grid()
132181

133182
t1 = time.time()
134183
msg = SnackbarMessage(f"'{previous_label}' sonified successfully in {t1-t0} seconds.",
135184
color='success',
136185
sender=self)
137186
self.app.hub.broadcast(msg)
138187

188+
def get_sonified_cube(self, sample_rate, buffer_size, device, assidx, ssvidx,
189+
pccut, audfrqmin, audfrqmax, eln, use_pccut, results_label):
190+
spectrum = self.dataset.selected_obj
191+
wlens = spectrum.wavelength.to('m').value
192+
flux = spectrum.flux.value
193+
self.sample_rate = sample_rate
194+
self.buffer_size = buffer_size
195+
196+
if self.sonification_wl_ranges:
197+
wdx = np.zeros(wlens.size).astype(bool)
198+
for r in self.sonification_wl_ranges:
199+
# index just the spectral subregion
200+
wdx = np.logical_or(wdx,
201+
np.logical_and(wlens >= r[0].to_value(u.m),
202+
wlens <= r[1].to_value(u.m)))
203+
wlens = wlens[wdx]
204+
flux_slices = [slice(None),]*3
205+
flux_slices[spectrum.spectral_axis_index] = wdx
206+
flux = flux[*flux_slices]
207+
208+
pc_cube = np.percentile(np.nan_to_num(flux), np.clip(pccut, 0, 99),
209+
axis=spectrum.spectral_axis_index)
210+
211+
# clip zeros and remove NaNs
212+
clipped_arr = np.nan_to_num(np.clip(flux, 0, np.inf), copy=False)
213+
214+
# make a rough white-light image from the clipped array
215+
whitelight = np.expand_dims(clipped_arr.sum(spectrum.spectral_axis_index),
216+
axis=spectrum.spectral_axis_index)
217+
218+
if use_pccut:
219+
# subtract any percentile cut
220+
clipped_arr -= np.expand_dims(pc_cube, axis=spectrum.spectral_axis_index)
221+
222+
# and re-clip
223+
clipped_arr = np.clip(clipped_arr, 0, np.inf)
224+
225+
self.sonified_cube = CubeListenerData(clipped_arr ** assidx, wlens, duration=0.8,
226+
samplerate=sample_rate, buffsize=buffer_size,
227+
wl_unit=self.sonification_wl_unit,
228+
audfrqmin=audfrqmin, audfrqmax=audfrqmax,
229+
eln=eln, vol=self.volume,
230+
spectral_axis_index=spectrum.spectral_axis_index)
231+
232+
self.sonified_cube.sonify_cube()
233+
self.sonified_cube.sigcube = (
234+
self.sonified_cube.sigcube * pow(whitelight / whitelight.max(),
235+
ssvidx)).astype('int16')
236+
self.stream = sd.OutputStream(samplerate=sample_rate, blocksize=buffer_size, device=device,
237+
channels=1, dtype='int16', latency='low',
238+
callback=self.sonified_cube.player_callback)
239+
self.sonified_cube.cbuff = True
240+
241+
spatial_inds = [0, 1, 2]
242+
spatial_inds.remove(spectrum.spectral_axis_index)
243+
x_size = self.sonified_cube.sigcube.shape[spatial_inds[0]]
244+
y_size = self.sonified_cube.sigcube.shape[spatial_inds[1]]
245+
246+
# Create a new entry for the sonified layer in data_lookup. The value is a dictionary
247+
# containing (x_size * y_size) keys with values being arrays that represent sounds
248+
if spectrum.spectral_axis_index == 2:
249+
self.data_lookup[results_label] = {(x, y): self.sonified_cube.sigcube[x, y, :]
250+
for x in range(0, x_size)
251+
for y in range(0, y_size)}
252+
elif spectrum.spectral_axis_index == 0:
253+
# This looks wrong but it's because in this case x_size is actually the y axis and vice
254+
# versa, wasn't sure about the best way to handle the spatial_inds thing above.
255+
self.data_lookup[results_label] = {(y, x): self.sonified_cube.sigcube[:, x, y]
256+
for x in range(0, x_size)
257+
for y in range(0, y_size)}
258+
259+
# Create a 2D array with coordinates starting at (0, 0) and going until (x_size, y_size)
260+
a = np.arange(1, x_size * y_size + 1).reshape((x_size, y_size))
261+
262+
# Attempt to copy the spatial WCS information from the cube
263+
if hasattr(spectrum.wcs, 'celestial'):
264+
wcs = spectrum.wcs.celestial
265+
elif hasattr(spectrum.wcs, 'to_fits_sip'):
266+
# GWCS case
267+
wcs = WCS(spectrum.wcs.to_fits_sip())
268+
else:
269+
wcs = None
270+
271+
sonified_cube = CCDData(a * u.Unit(''), wcs=wcs)
272+
return sonified_cube
273+
274+
def update_sonified_cube_with_coord(self, viewer, coord, vollim='buff'):
275+
# Set newsig to the combined sound array at coord
276+
if (int(coord[0]), int(coord[1])) not in viewer.combined_sonified_grid:
277+
return
278+
279+
# use cached version of combined sonified grid
280+
compsig = viewer.combined_sonified_grid[int(coord[0]), int(coord[1])]
281+
282+
# Adjust volume to remove clipping
283+
if vollim == 'sig':
284+
# sigmoidal volume limiting
285+
self.sonified_cube.newsig = (erf(compsig/INT_MAX) * INT_MAX).astype('int16')
286+
elif vollim == 'clip':
287+
# hard-clipped volume limiting
288+
self.sonified_cube.newsig = np.clip(compsig, -INT_MAX, INT_MAX).astype('int16')
289+
elif vollim == 'buff':
290+
# renormalise buffer
291+
sigmax = abs(compsig).max()
292+
if sigmax > INT_MAX:
293+
compsig = ((INT_MAX/abs(compsig).max()) * compsig)
294+
self.sonified_cube.newsig = compsig.astype('int16')
295+
self.sonified_cube.cbuff = True
296+
139297
@with_spinner()
140298
def vue_sonify_cube(self, *args):
141299
self.sonify_cube()
@@ -153,17 +311,26 @@ def update_wavelength_range(self, event):
153311
wlranges = self.spectral_subset.selected_obj.subregions
154312
else:
155313
wlranges = None
156-
self.flux_viewer.update_listener_wls(wlranges, display_unit)
314+
self.sonification_wl_ranges = wlranges
315+
self.sonification_wl_unit = display_unit
157316

158317
@observe('volume')
159318
def update_volume_level(self, event):
160-
self.flux_viewer.update_volume_level(event['new'])
319+
for viewer in self.sonified_viewers:
320+
viewer.update_volume_level(event['new'])
161321

162322
@observe('sound_devices_selected')
163323
def update_sound_device(self, event):
324+
# This might get called before the plugin is fully initialized
325+
if not hasattr(self, 'sonified_cube') or not self.sonified_cube:
326+
return
164327
if event['new'] != event['old']:
165328
didx = dict(zip(*self.build_device_lists()))[event['new']]
166-
self.flux_viewer.update_sound_device(didx)
329+
# This was moved here from viewers.py now that the stream is handled here.
330+
self.stop_stream()
331+
self.stream = sd.OutputStream(samplerate=self.sample_rate, blocksize=self.buffer_size,
332+
device=didx, channels=1, dtype='int16', latency='low',
333+
callback=self.sonified_cube.player_callback)
167334

168335
def refresh_device_list(self):
169336
devices, indexes = self.build_device_lists()

jdaviz/configs/cubeviz/plugins/sonify_data/sonify_data.vue

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,12 +147,11 @@
147147
label_hint="Label for the sonified data"
148148
:add_to_viewer_items="add_to_viewer_items"
149149
:add_to_viewer_selected.sync="add_to_viewer_selected"
150-
:add_to_viewer_enabled="false"
150+
add_to_viewer_hint="Add sonified layer to selected viewer. The sonified data will be available to add to all relevant viewers after creation."
151151
action_label="Sonify data"
152-
action_tooltip="Create sonified data and add to flux viewer"
152+
action_tooltip="Create sonified data and add to selected viewer"
153153
:action_spinner="spinner"
154154
action_api_hint='plg.sonify_cube()'
155-
:add_to_viewer_disabled="true"
156155
@click:action="sonify_cube"
157156
></plugin-add-results>
158157
</j-tray-plugin>

jdaviz/configs/cubeviz/plugins/sonify_data/tests/test_sonify_data.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22

33
import astropy.units as u
4+
from numpy.testing import assert_allclose
45
import pytest
56
from specutils import SpectralRegion
67

@@ -16,8 +17,8 @@ def test_sonify_data(cubeviz_helper, spectrum1d_cube_larger):
1617

1718
# Create sonified data cube
1819
sonify_plg.vue_sonify_cube()
19-
assert sonify_plg.flux_viewer.sonification_wl_ranges is None
20-
assert sonify_plg.flux_viewer.sonified_cube is not None
20+
assert sonify_plg.sonification_wl_ranges is None
21+
assert sonify_plg.sonified_cube is not None
2122

2223
# Test changing volume
2324
sonify_plg.volume = 90
@@ -29,8 +30,8 @@ def test_sonify_data(cubeviz_helper, spectrum1d_cube_larger):
2930
subset_plugin.import_region(spec_region)
3031
sonify_plg.spectral_subset_selected = 'Subset 1'
3132
sonify_plg.vue_sonify_cube()
32-
ranges = (sonify_plg.flux_viewer.sonification_wl_ranges[0][0].value,
33-
sonify_plg.flux_viewer.sonification_wl_ranges[0][1].value)
33+
ranges = (sonify_plg.sonification_wl_ranges[0][0].value,
34+
sonify_plg.sonification_wl_ranges[0][1].value)
3435
assert ranges == (4.62360028e-07, 4.62920561e-07)
3536

3637
# Stop/start stream
@@ -39,6 +40,21 @@ def test_sonify_data(cubeviz_helper, spectrum1d_cube_larger):
3940
sonify_plg.vue_start_stop_stream()
4041
assert sonify_plg.stream_active
4142

43+
# Add sonified data to uncert-viewer
44+
uncert_viewer = cubeviz_helper.viewers['uncert-viewer']._obj
45+
uncert_viewer.data_menu.add_data('Sonified data')
46+
assert 'Sonified data' in uncert_viewer.data_menu.data_labels_loaded
47+
48+
event_data = {'event': 'mousemove', 'domain': {'x': 1, 'y': 1}}
49+
uncert_viewer._viewer_mouse_event(event_data)
50+
51+
compsig = uncert_viewer.combined_sonified_grid[(1, 1)]
52+
sigmax = abs(compsig).max()
53+
INT_MAX = 2**15 - 1
54+
if sigmax > INT_MAX:
55+
compsig = ((INT_MAX/abs(compsig).max()) * compsig)
56+
assert_allclose(sonify_plg.sonified_cube.newsig, compsig)
57+
4258

4359
@pytest.mark.skipif(not IN_GITHUB_ACTIONS, reason="Plugin disabled only in CI")
4460
def test_sonify_data_disabled(cubeviz_helper, spectrum1d_cube_larger):

0 commit comments

Comments
 (0)