Repository: ipython/ipython

Issue:
Volume normalization in IPython.display.Audio should be optional
I am manipulating audio using numpy with IPython Notebook and want to use IPython.display.Audio for listening to the numpy arrays. Unfortunately, auto-normalization tampers with the results. Example:

```
# Generate a sound
import IPython
import numpy as np
framerate = 44100
t = np.linspace(0,5,framerate*5)
tone = np.sin(2*np.pi*220*t)
antitone = np.sin(2*np.pi*220*t + np.pi)
IPython.display.Audio(tone+antitone, rate=framerate)
```

Adding a sin wav to itself shifted by 180 deg should give total silence. Instead, auto-normalization amplifies the floating point errors. The problem is in the IPython.lib.display.Audio _make_wav method, which always normalizes the numpy array(see 'scaled' variable). I think that we should have a normalize keyword argument, so that Audio can be used for audio analysis. Something like:

```
Audio(tone+antitone, rate=framerate, normalize=False)
```


Comments:

Patch:
diff --git a/IPython/lib/display.py b/IPython/lib/display.py
index 73039bdb3..fe66f4f2b 100644
--- a/IPython/lib/display.py
+++ b/IPython/lib/display.py
@@ -54,6 +54,12 @@ class Audio(DisplayObject):
     autoplay : bool
         Set to True if the audio should immediately start playing.
         Default is `False`.
+    normalize : bool
+        Whether audio should be normalized (rescaled) to the maximum possible
+        range. Default is `True`. When set to `False`, `data` must be between
+        -1 and 1 (inclusive), otherwise an error is raised.
+        Applies only when `data` is a list or array of samples; other types of
+        audio are never normalized.
 
     Examples
     --------
@@ -83,9 +89,9 @@ class Audio(DisplayObject):
     """
     _read_flags = 'rb'
 
-    def __init__(self, data=None, filename=None, url=None, embed=None, rate=None, autoplay=False):
+    def __init__(self, data=None, filename=None, url=None, embed=None, rate=None, autoplay=False, normalize=True):
         if filename is None and url is None and data is None:
-            raise ValueError("No image data found. Expecting filename, url, or data.")
+            raise ValueError("No audio data found. Expecting filename, url, or data.")
         if embed is False and url is None:
             raise ValueError("No url found. Expecting url when embed=False")
 
@@ -97,7 +103,9 @@ def __init__(self, data=None, filename=None, url=None, embed=None, rate=None, au
         super(Audio, self).__init__(data=data, url=url, filename=filename)
 
         if self.data is not None and not isinstance(self.data, bytes):
-            self.data = self._make_wav(data,rate)
+            if rate is None:
+                raise ValueError("rate must be specified when data is a numpy array or list of audio samples.")
+            self.data = Audio._make_wav(data, rate, normalize)
 
     def reload(self):
         """Reload the raw data from file or URL."""
@@ -112,41 +120,17 @@ def reload(self):
         else:
             self.mimetype = "audio/wav"
 
-    def _make_wav(self, data, rate):
+    @staticmethod
+    def _make_wav(data, rate, normalize):
         """ Transform a numpy array to a PCM bytestring """
         import struct
         from io import BytesIO
         import wave
 
         try:
-            import numpy as np
-
-            data = np.array(data, dtype=float)
-            if len(data.shape) == 1:
-                nchan = 1
-            elif len(data.shape) == 2:
-                # In wave files,channels are interleaved. E.g.,
-                # "L1R1L2R2..." for stereo. See
-                # http://msdn.microsoft.com/en-us/library/windows/hardware/dn653308(v=vs.85).aspx
-                # for channel ordering
-                nchan = data.shape[0]
-                data = data.T.ravel()
-            else:
-                raise ValueError('Array audio input must be a 1D or 2D array')
-            scaled = np.int16(data/np.max(np.abs(data))*32767).tolist()
+            scaled, nchan = Audio._validate_and_normalize_with_numpy(data, normalize)
         except ImportError:
-            # check that it is a "1D" list
-            idata = iter(data)  # fails if not an iterable
-            try:
-                iter(idata.next())
-                raise TypeError('Only lists of mono audio are '
-                    'supported if numpy is not installed')
-            except TypeError:
-                # this means it's not a nested list, which is what we want
-                pass
-            maxabsvalue = float(max([abs(x) for x in data]))
-            scaled = [int(x/maxabsvalue*32767) for x in data]
-            nchan = 1
+            scaled, nchan = Audio._validate_and_normalize_without_numpy(data, normalize)
 
         fp = BytesIO()
         waveobj = wave.open(fp,mode='wb')
@@ -160,6 +144,48 @@ def _make_wav(self, data, rate):
 
         return val
 
+    @staticmethod
+    def _validate_and_normalize_with_numpy(data, normalize):
+        import numpy as np
+
+        data = np.array(data, dtype=float)
+        if len(data.shape) == 1:
+            nchan = 1
+        elif len(data.shape) == 2:
+            # In wave files,channels are interleaved. E.g.,
+            # "L1R1L2R2..." for stereo. See
+            # http://msdn.microsoft.com/en-us/library/windows/hardware/dn653308(v=vs.85).aspx
+            # for channel ordering
+            nchan = data.shape[0]
+            data = data.T.ravel()
+        else:
+            raise ValueError('Array audio input must be a 1D or 2D array')
+        
+        max_abs_value = np.max(np.abs(data))
+        normalization_factor = Audio._get_normalization_factor(max_abs_value, normalize)
+        scaled = np.int16(data / normalization_factor * 32767).tolist()
+        return scaled, nchan
+
+
+    @staticmethod
+    def _validate_and_normalize_without_numpy(data, normalize):
+        try:
+            max_abs_value = float(max([abs(x) for x in data]))
+        except TypeError:
+            raise TypeError('Only lists of mono audio are '
+                'supported if numpy is not installed')
+
+        normalization_factor = Audio._get_normalization_factor(max_abs_value, normalize)
+        scaled = [int(x / normalization_factor * 32767) for x in data]
+        nchan = 1
+        return scaled, nchan
+
+    @staticmethod
+    def _get_normalization_factor(max_abs_value, normalize):
+        if not normalize and max_abs_value > 1:
+            raise ValueError('Audio data must be between -1 and 1 when normalize=False.')
+        return max_abs_value if normalize else 1
+
     def _data_and_metadata(self):
         """shortcut for returning metadata with url information, if defined"""
         md = {}
diff --git a/docs/source/whatsnew/pr/optional-audio-normalization.rst b/docs/source/whatsnew/pr/optional-audio-normalization.rst
new file mode 100644
index 000000000..c4bd33361
--- /dev/null
+++ b/docs/source/whatsnew/pr/optional-audio-normalization.rst
@@ -0,0 +1,7 @@
+Make audio normalization optional
+=================================
+
+Added 'normalize' argument to `IPython.display.Audio`. This argument applies
+when audio data is given as an array of samples. The default of `normalize=True`
+preserves prior behavior of normalizing the audio to the maximum possible range.
+Setting to `False` disables normalization.
\ No newline at end of file


Test patch:
diff --git a/IPython/lib/tests/test_display.py b/IPython/lib/tests/test_display.py
index aa0d617ed..9854ba635 100644
--- a/IPython/lib/tests/test_display.py
+++ b/IPython/lib/tests/test_display.py
@@ -19,13 +19,17 @@
     import pathlib
 except ImportError:
     pass
+from unittest import TestCase, mock
+import struct
+import wave
+from io import BytesIO
 
 # Third-party imports
 import nose.tools as nt
+import numpy
 
 # Our own imports
 from IPython.lib import display
-from IPython.testing.decorators import skipif_not_numpy
 
 #-----------------------------------------------------------------------------
 # Classes and functions
@@ -179,11 +183,71 @@ def test_recursive_FileLinks():
     actual = actual.split('\n')
     nt.assert_equal(len(actual), 2, actual)
 
-@skipif_not_numpy
 def test_audio_from_file():
     path = pjoin(dirname(__file__), 'test.wav')
     display.Audio(filename=path)
 
+class TestAudioDataWithNumpy(TestCase):
+    def test_audio_from_numpy_array(self):
+        test_tone = get_test_tone()
+        audio = display.Audio(test_tone, rate=44100)
+        nt.assert_equal(len(read_wav(audio.data)), len(test_tone))
+
+    def test_audio_from_list(self):
+        test_tone = get_test_tone()
+        audio = display.Audio(list(test_tone), rate=44100)
+        nt.assert_equal(len(read_wav(audio.data)), len(test_tone))
+
+    def test_audio_from_numpy_array_without_rate_raises(self):
+        nt.assert_raises(ValueError, display.Audio, get_test_tone())
+
+    def test_audio_data_normalization(self):
+        expected_max_value = numpy.iinfo(numpy.int16).max
+        for scale in [1, 0.5, 2]:
+            audio = display.Audio(get_test_tone(scale), rate=44100)
+            actual_max_value = numpy.max(numpy.abs(read_wav(audio.data)))
+            nt.assert_equal(actual_max_value, expected_max_value)
+
+    def test_audio_data_without_normalization(self):
+        max_int16 = numpy.iinfo(numpy.int16).max
+        for scale in [1, 0.5, 0.2]:
+            test_tone = get_test_tone(scale)
+            test_tone_max_abs = numpy.max(numpy.abs(test_tone))
+            expected_max_value = int(max_int16 * test_tone_max_abs)
+            audio = display.Audio(test_tone, rate=44100, normalize=False)
+            actual_max_value = numpy.max(numpy.abs(read_wav(audio.data)))
+            nt.assert_equal(actual_max_value, expected_max_value)
+
+    def test_audio_data_without_normalization_raises_for_invalid_data(self):
+        nt.assert_raises(
+            ValueError,
+            lambda: display.Audio([1.001], rate=44100, normalize=False))
+        nt.assert_raises(
+            ValueError,
+            lambda: display.Audio([-1.001], rate=44100, normalize=False))
+
+def simulate_numpy_not_installed():
+    return mock.patch('numpy.array', mock.MagicMock(side_effect=ImportError))
+
+@simulate_numpy_not_installed()
+class TestAudioDataWithoutNumpy(TestAudioDataWithNumpy):
+    # All tests from `TestAudioDataWithNumpy` are inherited.
+
+    def test_audio_raises_for_nested_list(self):
+        stereo_signal = [list(get_test_tone())] * 2
+        nt.assert_raises(
+            TypeError,
+            lambda: display.Audio(stereo_signal, rate=44100))
+
+def get_test_tone(scale=1):
+    return numpy.sin(2 * numpy.pi * 440 * numpy.linspace(0, 1, 44100)) * scale
+
+def read_wav(data):
+    with wave.open(BytesIO(data)) as wave_file:
+        wave_data = wave_file.readframes(wave_file.getnframes())
+        num_samples = wave_file.getnframes() * wave_file.getnchannels()
+        return struct.unpack('<%sh' % num_samples, wave_data)
+
 def test_code_from_file():
     c = display.Code(filename=__file__)
     assert c._repr_html_().startswith('<style>')