11import time
2- from traitlets import Bool , List , Unicode , observe
2+
3+ from astropy .nddata import CCDData
34import 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
510from jdaviz .core .custom_traitlets import IntHandleEmpty , FloatHandleEmpty
611from jdaviz .core .registries import tray_registry
712from jdaviz .core .template_mixin import (PluginTemplateMixin , DatasetSelectMixin ,
813 SpectralSubsetSelectMixin , with_spinner ,
914 AddResultsMixin )
1015from 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 ()
0 commit comments