##// END OF EJS Templates
Add normalize parameter to Audio.
Matan Gover -
Show More
@@ -54,6 +54,12 b' class Audio(DisplayObject):'
54 autoplay : bool
54 autoplay : bool
55 Set to True if the audio should immediately start playing.
55 Set to True if the audio should immediately start playing.
56 Default is `False`.
56 Default is `False`.
57 normalize : bool
58 Whether audio should be normalized (rescaled) to the maximum possible
59 range. Default is `True`. When set to `False`, `data` must be between
60 -1 and 1 (inclusive), otherwise an error is raised.
61 Applies only when `data` is a list or array of samples; other types of
62 audio are never normalized.
57
63
58 Examples
64 Examples
59 --------
65 --------
@@ -83,7 +89,7 b' class Audio(DisplayObject):'
83 """
89 """
84 _read_flags = 'rb'
90 _read_flags = 'rb'
85
91
86 def __init__(self, data=None, filename=None, url=None, embed=None, rate=None, autoplay=False):
92 def __init__(self, data=None, filename=None, url=None, embed=None, rate=None, autoplay=False, normalize=True):
87 if filename is None and url is None and data is None:
93 if filename is None and url is None and data is None:
88 raise ValueError("No audio data found. Expecting filename, url, or data.")
94 raise ValueError("No audio data found. Expecting filename, url, or data.")
89 if embed is False and url is None:
95 if embed is False and url is None:
@@ -99,7 +105,7 b' class Audio(DisplayObject):'
99 if self.data is not None and not isinstance(self.data, bytes):
105 if self.data is not None and not isinstance(self.data, bytes):
100 if rate is None:
106 if rate is None:
101 raise ValueError("rate must be specified when data is a numpy array or list of audio samples.")
107 raise ValueError("rate must be specified when data is a numpy array or list of audio samples.")
102 self.data = Audio._make_wav(data, rate)
108 self.data = Audio._make_wav(data, rate, normalize)
103
109
104 def reload(self):
110 def reload(self):
105 """Reload the raw data from file or URL."""
111 """Reload the raw data from file or URL."""
@@ -115,16 +121,16 b' class Audio(DisplayObject):'
115 self.mimetype = "audio/wav"
121 self.mimetype = "audio/wav"
116
122
117 @staticmethod
123 @staticmethod
118 def _make_wav(data, rate):
124 def _make_wav(data, rate, normalize):
119 """ Transform a numpy array to a PCM bytestring """
125 """ Transform a numpy array to a PCM bytestring """
120 import struct
126 import struct
121 from io import BytesIO
127 from io import BytesIO
122 import wave
128 import wave
123
129
124 try:
130 try:
125 scaled, nchan = Audio._validate_and_normalize_with_numpy(data)
131 scaled, nchan = Audio._validate_and_normalize_with_numpy(data, normalize)
126 except ImportError:
132 except ImportError:
127 scaled, nchan = Audio._validate_and_normalize_without_numpy(data)
133 scaled, nchan = Audio._validate_and_normalize_without_numpy(data, normalize)
128
134
129 fp = BytesIO()
135 fp = BytesIO()
130 waveobj = wave.open(fp,mode='wb')
136 waveobj = wave.open(fp,mode='wb')
@@ -139,7 +145,7 b' class Audio(DisplayObject):'
139 return val
145 return val
140
146
141 @staticmethod
147 @staticmethod
142 def _validate_and_normalize_with_numpy(data):
148 def _validate_and_normalize_with_numpy(data, normalize):
143 import numpy as np
149 import numpy as np
144
150
145 data = np.array(data, dtype=float)
151 data = np.array(data, dtype=float)
@@ -154,21 +160,32 b' class Audio(DisplayObject):'
154 data = data.T.ravel()
160 data = data.T.ravel()
155 else:
161 else:
156 raise ValueError('Array audio input must be a 1D or 2D array')
162 raise ValueError('Array audio input must be a 1D or 2D array')
157 scaled = np.int16(data/np.max(np.abs(data))*32767).tolist()
163
164 max_abs_value = np.max(np.abs(data))
165 normalization_factor = Audio._get_normalization_factor(max_abs_value, normalize)
166 scaled = np.int16(data / normalization_factor * 32767).tolist()
158 return scaled, nchan
167 return scaled, nchan
159
168
169
160 @staticmethod
170 @staticmethod
161 def _validate_and_normalize_without_numpy(data):
171 def _validate_and_normalize_without_numpy(data, normalize):
162 try:
172 try:
163 maxabsvalue = float(max([abs(x) for x in data]))
173 max_abs_value = float(max([abs(x) for x in data]))
164 except TypeError:
174 except TypeError:
165 raise TypeError('Only lists of mono audio are '
175 raise TypeError('Only lists of mono audio are '
166 'supported if numpy is not installed')
176 'supported if numpy is not installed')
167
177
168 scaled = [int(x/maxabsvalue*32767) for x in data]
178 normalization_factor = Audio._get_normalization_factor(max_abs_value, normalize)
179 scaled = [int(x / normalization_factor * 32767) for x in data]
169 nchan = 1
180 nchan = 1
170 return scaled, nchan
181 return scaled, nchan
171
182
183 @staticmethod
184 def _get_normalization_factor(max_abs_value, normalize):
185 if not normalize and max_abs_value > 1:
186 raise ValueError('Audio data must be between -1 and 1 when normalize=False.')
187 return max_abs_value if normalize else 1
188
172 def _data_and_metadata(self):
189 def _data_and_metadata(self):
173 """shortcut for returning metadata with url information, if defined"""
190 """shortcut for returning metadata with url information, if defined"""
174 md = {}
191 md = {}
@@ -19,7 +19,10 b' try:'
19 import pathlib
19 import pathlib
20 except ImportError:
20 except ImportError:
21 pass
21 pass
22 from unittest import mock
22 from unittest import TestCase, mock
23 import struct
24 import wave
25 from io import BytesIO
23
26
24 # Third-party imports
27 # Third-party imports
25 import nose.tools as nt
28 import nose.tools as nt
@@ -184,25 +187,66 b' def test_audio_from_file():'
184 path = pjoin(dirname(__file__), 'test.wav')
187 path = pjoin(dirname(__file__), 'test.wav')
185 display.Audio(filename=path)
188 display.Audio(filename=path)
186
189
187 def test_audio_from_numpy_array():
190 class TestAudioDataWithNumpy(TestCase):
188 display.Audio(get_test_tone(), rate=44100)
191 def test_audio_from_numpy_array(self):
189
192 test_tone = get_test_tone()
190 def test_audio_from_list_without_numpy():
193 audio = display.Audio(test_tone, rate=44100)
191 # Simulate numpy not installed.
194 nt.assert_equal(len(read_wav(audio.data)), len(test_tone))
192 with mock.patch('numpy.array', side_effect=ImportError):
195
193 display.Audio(list(get_test_tone()), rate=44100)
196 def test_audio_from_list(self):
194
197 test_tone = get_test_tone()
195 def test_audio_from_list_without_numpy_raises_for_nested_list():
198 audio = display.Audio(list(test_tone), rate=44100)
196 # Simulate numpy not installed.
199 nt.assert_equal(len(read_wav(audio.data)), len(test_tone))
197 with mock.patch('numpy.array', side_effect=ImportError):
200
201 def test_audio_from_numpy_array_without_rate_raises(self):
202 nt.assert_raises(ValueError, display.Audio, get_test_tone())
203
204 def test_audio_data_normalization(self):
205 expected_max_value = numpy.iinfo(numpy.int16).max
206 for scale in [1, 0.5, 2]:
207 audio = display.Audio(get_test_tone(scale), rate=44100)
208 actual_max_value = numpy.max(numpy.abs(read_wav(audio.data)))
209 nt.assert_equal(actual_max_value, expected_max_value)
210
211 def test_audio_data_without_normalization(self):
212 max_int16 = numpy.iinfo(numpy.int16).max
213 for scale in [1, 0.5, 0.2]:
214 test_tone = get_test_tone(scale)
215 test_tone_max_abs = numpy.max(numpy.abs(test_tone))
216 expected_max_value = int(max_int16 * test_tone_max_abs)
217 audio = display.Audio(test_tone, rate=44100, normalize=False)
218 actual_max_value = numpy.max(numpy.abs(read_wav(audio.data)))
219 nt.assert_equal(actual_max_value, expected_max_value)
220
221 def test_audio_data_without_normalization_raises_for_invalid_data(self):
222 nt.assert_raises(
223 ValueError,
224 lambda: display.Audio([1.001], rate=44100, normalize=False))
225 nt.assert_raises(
226 ValueError,
227 lambda: display.Audio([-1.001], rate=44100, normalize=False))
228
229 def simulate_numpy_not_installed():
230 return mock.patch('numpy.array', mock.MagicMock(side_effect=ImportError))
231
232 @simulate_numpy_not_installed()
233 class TestAudioDataWithoutNumpy(TestAudioDataWithNumpy):
234 # All tests from `TestAudioDataWithNumpy` are inherited.
235
236 def test_audio_raises_for_nested_list(self):
198 stereo_signal = [list(get_test_tone())] * 2
237 stereo_signal = [list(get_test_tone())] * 2
199 nt.assert_raises(TypeError, lambda: display.Audio(stereo_signal, rate=44100))
238 nt.assert_raises(
200
239 TypeError,
201 def test_audio_from_numpy_array_without_rate_raises():
240 lambda: display.Audio(stereo_signal, rate=44100))
202 nt.assert_raises(ValueError, display.Audio, get_test_tone())
241
203
242 def get_test_tone(scale=1):
204 def get_test_tone():
243 return numpy.sin(2 * numpy.pi * 440 * numpy.linspace(0, 1, 44100)) * scale
205 return numpy.sin(2 * numpy.pi * 440 * numpy.linspace(0, 1, 44100))
244
245 def read_wav(data):
246 with wave.open(BytesIO(data)) as wave_file:
247 wave_data = wave_file.readframes(wave_file.getnframes())
248 num_samples = wave_file.getnframes() * wave_file.getnchannels()
249 return struct.unpack('<%sh' % num_samples, wave_data)
206
250
207 def test_code_from_file():
251 def test_code_from_file():
208 c = display.Code(filename=__file__)
252 c = display.Code(filename=__file__)
General Comments 0
You need to be logged in to leave comments. Login now