diff --git a/Doc/library/wave.rst b/Doc/library/wave.rst index 6e61a1a44ad232..f03b8b75c19973 100644 --- a/Doc/library/wave.rst +++ b/Doc/library/wave.rst @@ -9,14 +9,19 @@ -------------- The :mod:`!wave` module provides a convenient interface to the Waveform Audio -"WAVE" (or "WAV") file format. Only uncompressed PCM encoded wave files are -supported. +"WAVE" (or "WAV") file format. + +The module supports uncompressed PCM and IEEE floating-point WAV formats. .. versionchanged:: 3.12 Support for ``WAVE_FORMAT_EXTENSIBLE`` headers was added, provided that the extended format is ``KSDATAFORMAT_SUBTYPE_PCM``. +.. versionchanged:: 3.15 + + Support for reading and writing ``WAVE_FORMAT_IEEE_FLOAT`` files was added. + The :mod:`!wave` module defines the following function and exception: @@ -98,6 +103,14 @@ Wave_read Objects Returns number of audio frames. + .. method:: getformat() + + Returns the frame format code. + + This is one of ``WAVE_FORMAT_PCM``, ``WAVE_FORMAT_IEEE_FLOAT``, or + ``WAVE_FORMAT_EXTENSIBLE``. + + .. method:: getcomptype() Returns compression type (``'NONE'`` is the only supported type). @@ -112,8 +125,8 @@ Wave_read Objects .. method:: getparams() Returns a :func:`~collections.namedtuple` ``(nchannels, sampwidth, - framerate, nframes, comptype, compname)``, equivalent to output of the - ``get*()`` methods. + framerate, nframes, comptype, compname, format)``, equivalent to output + of the ``get*()`` methods. .. method:: readframes(n) @@ -181,11 +194,21 @@ Wave_write Objects Set the number of channels. + .. method:: getnchannels() + + Return the number of channels. + + .. method:: setsampwidth(n) Set the sample width to *n* bytes. + .. method:: getsampwidth() + + Return the sample width in bytes. + + .. method:: setframerate(n) Set the frame rate to *n*. @@ -195,6 +218,11 @@ Wave_write Objects integer. + .. method:: getframerate() + + Return the frame rate. + + .. method:: setnframes(n) Set the number of frames to *n*. This will be changed later if the number @@ -202,17 +230,55 @@ Wave_write Objects raise an error if the output stream is not seekable). + .. method:: getnframes() + + Return the number of audio frames written so far. + + .. method:: setcomptype(type, name) Set the compression type and description. At the moment, only compression type ``NONE`` is supported, meaning no compression. + .. method:: getcomptype() + + Return the compression type (``'NONE'``). + + + .. method:: getcompname() + + Return the human-readable compression type name. + + + .. method:: setformat(format) + + Set the frame format code. + + Supported values are ``WAVE_FORMAT_PCM`` and + ``WAVE_FORMAT_IEEE_FLOAT``. + + + .. method:: getformat() + + Return the current frame format code. + + .. method:: setparams(tuple) - The *tuple* should be ``(nchannels, sampwidth, framerate, nframes, comptype, - compname)``, with values valid for the ``set*()`` methods. Sets all - parameters. + The *tuple* should be + ``(nchannels, sampwidth, framerate, nframes, comptype, compname, format)``, + with values valid for the ``set*()`` methods. Sets all parameters. + + For backwards compatibility, a 6-item tuple without *format* is also + accepted and defaults to ``WAVE_FORMAT_PCM``. + + + .. method:: getparams() + + Return a :func:`~collections.namedtuple` + ``(nchannels, sampwidth, framerate, nframes, comptype, compname, format)`` + containing the current output parameters. .. method:: tell() @@ -242,3 +308,6 @@ Wave_write Objects Note that it is invalid to set any parameters after calling :meth:`writeframes` or :meth:`writeframesraw`, and any attempt to do so will raise :exc:`wave.Error`. + + For ``WAVE_FORMAT_IEEE_FLOAT`` output, a ``fact`` chunk is written as + required by the WAVE specification for non-PCM formats. diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 63ef5f84301794..6b099bd330a0e5 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -1493,6 +1493,23 @@ typing wave ---- +* Added support for IEEE floating-point WAVE audio + (``WAVE_FORMAT_IEEE_FLOAT``) in :mod:`wave`. + +* Added :meth:`wave.Wave_read.getformat`, :meth:`wave.Wave_write.getformat`, + and :meth:`wave.Wave_write.setformat` for explicit frame format handling. + +* :meth:`wave.Wave_read.getparams` and :meth:`wave.Wave_write.getparams` now + include ``format`` as the seventh field in the returned named tuple. + :meth:`wave.Wave_write.setparams` accepts both 7-item tuples including + ``format`` and 6-item tuples for backwards compatibility (defaulting to + ``WAVE_FORMAT_PCM``). + +* ``WAVE_FORMAT_IEEE_FLOAT`` output now includes a ``fact`` chunk, + as required for non-PCM WAVE formats. + +(Contributed by Lionel Koenig and Michiel W. Beijen in :gh:`60729`.) + * Removed the ``getmark()``, ``setmark()`` and ``getmarkers()`` methods of the :class:`~wave.Wave_read` and :class:`~wave.Wave_write` classes, which were deprecated since Python 3.13. diff --git a/Lib/test/audiodata/pluck-float32.wav b/Lib/test/audiodata/pluck-float32.wav new file mode 100644 index 00000000000000..2030fb16d6e3bd Binary files /dev/null and b/Lib/test/audiodata/pluck-float32.wav differ diff --git a/Lib/test/audiotests.py b/Lib/test/audiotests.py index 9d6c4cc2b4b02c..ddf9effb18c12e 100644 --- a/Lib/test/audiotests.py +++ b/Lib/test/audiotests.py @@ -27,23 +27,25 @@ def tearDown(self): unlink(TESTFN) def check_params(self, f, nchannels, sampwidth, framerate, nframes, - comptype, compname): + comptype, compname, format): self.assertEqual(f.getnchannels(), nchannels) self.assertEqual(f.getsampwidth(), sampwidth) self.assertEqual(f.getframerate(), framerate) self.assertEqual(f.getnframes(), nframes) self.assertEqual(f.getcomptype(), comptype) self.assertEqual(f.getcompname(), compname) + self.assertEqual(f.getformat(), format) params = f.getparams() self.assertEqual(params, - (nchannels, sampwidth, framerate, nframes, comptype, compname)) + (nchannels, sampwidth, framerate, nframes, comptype, compname, format)) self.assertEqual(params.nchannels, nchannels) self.assertEqual(params.sampwidth, sampwidth) self.assertEqual(params.framerate, framerate) self.assertEqual(params.nframes, nframes) self.assertEqual(params.comptype, comptype) self.assertEqual(params.compname, compname) + self.assertEqual(params.format, format) for proto in range(pickle.HIGHEST_PROTOCOL + 1): dump = pickle.dumps(params, proto) @@ -51,13 +53,17 @@ def check_params(self, f, nchannels, sampwidth, framerate, nframes, class AudioWriteTests(AudioTests): + readonly = False def create_file(self, testfile): + if self.readonly: + self.skipTest('Read only file format') f = self.fout = self.module.open(testfile, 'wb') f.setnchannels(self.nchannels) f.setsampwidth(self.sampwidth) f.setframerate(self.framerate) f.setcomptype(self.comptype, self.compname) + f.setformat(self.format) return f def check_file(self, testfile, nframes, frames): @@ -67,13 +73,14 @@ def check_file(self, testfile, nframes, frames): self.assertEqual(f.getframerate(), self.framerate) self.assertEqual(f.getnframes(), nframes) self.assertEqual(f.readframes(nframes), frames) + self.assertEqual(f.getformat(), self.format) def test_write_params(self): f = self.create_file(TESTFN) f.setnframes(self.nframes) f.writeframes(self.frames) self.check_params(f, self.nchannels, self.sampwidth, self.framerate, - self.nframes, self.comptype, self.compname) + self.nframes, self.comptype, self.compname, self.format) f.close() def test_write_context_manager_calls_close(self): @@ -257,7 +264,7 @@ def test_read_params(self): f = self.f = self.module.open(self.sndfilepath) #self.assertEqual(f.getfp().name, self.sndfilepath) self.check_params(f, self.nchannels, self.sampwidth, self.framerate, - self.sndfilenframes, self.comptype, self.compname) + self.sndfilenframes, self.comptype, self.compname, self.format) def test_close(self): with open(self.sndfilepath, 'rb') as testfile: @@ -298,6 +305,8 @@ def test_read(self): f.setpos(f.getnframes() + 1) def test_copy(self): + if self.readonly: + self.skipTest('Read only file format') f = self.f = self.module.open(self.sndfilepath) fout = self.fout = self.module.open(TESTFN, 'wb') fout.setparams(f.getparams()) diff --git a/Lib/test/test_wave.py b/Lib/test/test_wave.py index 4c21f16553775c..635b5ee3a6901f 100644 --- a/Lib/test/test_wave.py +++ b/Lib/test/test_wave.py @@ -1,7 +1,7 @@ import unittest from test import audiotests from test import support -from test.support.os_helper import FakePath +from test.support.os_helper import FakePath, unlink import io import os import struct @@ -22,6 +22,7 @@ class WavePCM8Test(WaveTest, unittest.TestCase): sampwidth = 1 framerate = 11025 nframes = 48 + format = wave.WAVE_FORMAT_PCM comptype = 'NONE' compname = 'not compressed' frames = bytes.fromhex("""\ @@ -39,6 +40,7 @@ class WavePCM16Test(WaveTest, unittest.TestCase): sampwidth = 2 framerate = 11025 nframes = 48 + format = wave.WAVE_FORMAT_PCM comptype = 'NONE' compname = 'not compressed' frames = bytes.fromhex("""\ @@ -60,6 +62,7 @@ class WavePCM24Test(WaveTest, unittest.TestCase): sampwidth = 3 framerate = 11025 nframes = 48 + format = wave.WAVE_FORMAT_PCM comptype = 'NONE' compname = 'not compressed' frames = bytes.fromhex("""\ @@ -87,6 +90,8 @@ class WavePCM24ExtTest(WaveTest, unittest.TestCase): sampwidth = 3 framerate = 11025 nframes = 48 + format = wave.WAVE_FORMAT_EXTENSIBLE + readonly = True # Writing EXTENSIBLE wave format is not supported. comptype = 'NONE' compname = 'not compressed' frames = bytes.fromhex("""\ @@ -114,6 +119,7 @@ class WavePCM32Test(WaveTest, unittest.TestCase): sampwidth = 4 framerate = 11025 nframes = 48 + format = wave.WAVE_FORMAT_PCM comptype = 'NONE' compname = 'not compressed' frames = bytes.fromhex("""\ @@ -134,14 +140,91 @@ class WavePCM32Test(WaveTest, unittest.TestCase): frames = wave._byteswap(frames, 4) +class WaveIeeeFloatingPointTest(WaveTest, unittest.TestCase): + sndfilename = 'pluck-float32.wav' + sndfilenframes = 3307 + nchannels = 2 + sampwidth = 4 + framerate = 11025 + nframes = 48 + format = wave.WAVE_FORMAT_IEEE_FLOAT + comptype = 'NONE' + compname = 'not compressed' + frames = bytes.fromhex("""\ + 60598B3C001423BA 1FB4163F8054FA3B 0E4FC43E80C51D3D 53467EBF4030843D \ + FC84D0BE304C563D 3053113F40BEFC3C B72F00BFC03E583C E0FEDA3C805142BC \ + 54510FBFE02638BD 569F16BF40FDCABD C060A63EECA421BE 3CE5523E2C3349BE \ + 0C2E10BE14725BBE 5268E7BEDC3B6CBE 985AE03D80497ABE B4B606BEECB67EBE \ + B0B12E3FC87C6CBE 005519BD4C0F3EBE F8BD1B3EECDF03BE 924E9FBE588D8DBD \ + D4E150BF501711BD B079A0BD20FBFBBC 5863863D40760CBD 0E3C83BE40E217BD \ + 04FF0B3EF07839BD E29AFB3E80A714BD B91007BFE042D3BC B5AD4D3F80CDA0BB \ + 1AB1C3BEB04E023D D33A063FC0A8973D 8012F9BEE074EC3D 7341223FD415153E \ + D80409BE04A63A3E 00F27BBFBC25333E 0000803FFC29223E 000080BF38A7143E \ + 3638133F283BEB3D 7C6E253F00CADB3D 686A02BE88FDF53D 920CC7BE28E1FB3D \ + 185B5ABED8A2CE3D 5189463FC8A7A53D E88F8C3DF0FFA13D 1CE6AE3EE0A0B03D \ + DF90223F184EE43D 376768BF2CD8093E 281612BF60B3EE3D 2F26083F88B4A53D \ + """) + class MiscTestCase(unittest.TestCase): def test__all__(self): - not_exported = {'WAVE_FORMAT_PCM', 'WAVE_FORMAT_EXTENSIBLE', 'KSDATAFORMAT_SUBTYPE_PCM'} + not_exported = {'WAVE_FORMAT_PCM', 'WAVE_FORMAT_IEEE_FLOAT', 'WAVE_FORMAT_EXTENSIBLE', 'KSDATAFORMAT_SUBTYPE_PCM'} support.check__all__(self, wave, not_exported=not_exported) class WaveLowLevelTest(unittest.TestCase): + def test_setparams_6_tuple_defaults_to_pcm(self): + with tempfile.NamedTemporaryFile(delete_on_close=False) as fp: + filename = fp.name + self.addCleanup(unlink, filename) + + with wave.open(filename, 'wb') as w: + w.setformat(wave.WAVE_FORMAT_IEEE_FLOAT) + w.setparams((1, 2, 22050, 0, 'NONE', 'not compressed')) + self.assertEqual(w.getformat(), wave.WAVE_FORMAT_PCM) + + def test_setparams_7_tuple_uses_format(self): + with tempfile.NamedTemporaryFile(delete_on_close=False) as fp: + filename = fp.name + self.addCleanup(unlink, filename) + + with wave.open(filename, 'wb') as w: + w.setparams((1, 2, 22050, 0, 'NONE', 'not compressed', + wave.WAVE_FORMAT_IEEE_FLOAT)) + self.assertEqual(w.getformat(), wave.WAVE_FORMAT_IEEE_FLOAT) + + def test_getparams_has_format_field(self): + with tempfile.NamedTemporaryFile(delete_on_close=False) as fp: + filename = fp.name + self.addCleanup(unlink, filename) + + with wave.open(filename, 'wb') as w: + w.setparams((1, 2, 22050, 0, 'NONE', 'not compressed', + wave.WAVE_FORMAT_IEEE_FLOAT)) + params = w.getparams() + self.assertEqual(params.format, wave.WAVE_FORMAT_IEEE_FLOAT) + self.assertEqual(params[:6], (1, 2, 22050, 0, 'NONE', 'not compressed')) + + def test_getformat_setformat(self): + with tempfile.NamedTemporaryFile(delete_on_close=False) as fp: + filename = fp.name + self.addCleanup(unlink, filename) + + with wave.open(filename, 'wb') as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(22050) + self.assertEqual(w.getformat(), wave.WAVE_FORMAT_PCM) + w.setformat(wave.WAVE_FORMAT_IEEE_FLOAT) + self.assertEqual(w.getformat(), wave.WAVE_FORMAT_IEEE_FLOAT) + + def test_read_getformat(self): + b = b'RIFF' + struct.pack(' self._nframes: @@ -367,16 +382,16 @@ def readframes(self, nframes): def _read_fmt_chunk(self, chunk): try: - wFormatTag, self._nchannels, self._framerate, dwAvgBytesPerSec, wBlockAlign = struct.unpack_from('