From 110e810b2300accab9632b1cc2540211cfc39c0e Mon Sep 17 00:00:00 2001 From: ytya Date: Mon, 11 Nov 2024 22:09:17 +0900 Subject: [PATCH 1/4] Add functions to change compression level and bitrate mode. --- soundfile.py | 69 ++++++++++++++++++++++++++++++++++++++--- soundfile_build.py | 8 +++++ tests/test_argspec.py | 4 +++ tests/test_soundfile.py | 54 ++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 5 deletions(-) diff --git a/soundfile.py b/soundfile.py index 1fd2b73..e60bc37 100644 --- a/soundfile.py +++ b/soundfile.py @@ -145,6 +145,12 @@ 'int16': 'short' } +_bitrate_modes = { + 'CONSTANT': 0, + 'AVERAGE': 1, + 'VARIABLE': 2, +} + try: # packaged lib (in _soundfile_data which should be on python path) if _sys.platform == 'darwin': from platform import machine as _machine @@ -290,7 +296,7 @@ def read(file, frames=-1, start=0, stop=None, dtype='float64', always_2d=False, def write(file, data, samplerate, subtype=None, endian=None, format=None, - closefd=True): + closefd=True, compression_level=None, bitrate_mode=None): """Write data to a sound file. .. note:: If *file* exists, it will be truncated and overwritten! @@ -325,6 +331,11 @@ def write(file, data, samplerate, subtype=None, endian=None, format=None, format, endian, closefd See `SoundFile`. + compression_level : float, optional + See `libsndfile document `__. + bitrate_mode : {'CONSTANT', 'AVERAGE', 'VARIABLE'}, optional + See `libsndfile document `__. + Examples -------- Write 10 frames of random data to a new file: @@ -342,7 +353,7 @@ def write(file, data, samplerate, subtype=None, endian=None, format=None, channels = data.shape[1] with SoundFile(file, 'w', samplerate, channels, subtype, endian, format, closefd) as f: - f.write(data) + f.write(data, compression_level, bitrate_mode) def blocks(file, blocksize=None, overlap=0, frames=-1, start=0, stop=None, @@ -968,7 +979,7 @@ def buffer_read_into(self, buffer, dtype): frames = self._cdata_io('read', cdata, ctype, frames) return frames - def write(self, data): + def write(self, data, compression_level=None, bitrate_mode=None): """Write audio data from a NumPy array to the file. Writes a number of frames at the read/write position to the @@ -998,6 +1009,12 @@ def write(self, data): file will then contain ``np.array([42.], dtype='float32')``. + Other Parameters + ---------------- + compression_level : float, optional + bitrate_mode : {'CONSTANT', 'AVERAGE', 'VARIABLE'}, optional + See `libsndfile document `__. + Examples -------- >>> import numpy as np @@ -1015,13 +1032,20 @@ def write(self, data): """ import numpy as np + + if compression_level is not None: + # needs to be called before set_bitrate_mode + self.set_compression_level(compression_level) + if bitrate_mode is not None: + self.set_bitrate_mode(bitrate_mode) + # no copy is made if data has already the correct memory layout: data = np.ascontiguousarray(data) written = self._array_io('write', data, len(data)) assert written == len(data) self._update_frames(written) - def buffer_write(self, data, dtype): + def buffer_write(self, data, dtype, compression_level=None, bitrate_mode=None): """Write audio data from a buffer/bytes object to the file. Writes the contents of *data* to the file at the current @@ -1037,11 +1061,23 @@ def buffer_write(self, data, dtype): dtype : {'float64', 'float32', 'int32', 'int16'} The data type of the audio data stored in *data*. + Other Parameters + ---------------- + compression_level : float, optional + bitrate_mode : {'CONSTANT', 'AVERAGE', 'VARIABLE'}, optional + See `libsndfile document `__. + See Also -------- .write, buffer_read """ + if compression_level is not None: + # needs to be called before set_bitrate_mode + self.set_compression_level(compression_level) + if bitrate_mode is not None: + self.set_bitrate_mode(bitrate_mode) + ctype = self._check_dtype(dtype) cdata, frames = self._check_buffer(data, ctype) written = self._cdata_io('write', cdata, ctype, frames) @@ -1399,7 +1435,30 @@ def copy_metadata(self): if data: strs[strtype] = _ffi.string(data).decode('utf-8', 'replace') return strs - + + def set_bitrate_mode(self, bitrate_mode): + """Call libsndfile's set bitrate mode function.""" + assert bitrate_mode in _bitrate_modes + + pointer_bitrate_mode = _ffi.new("int[1]") + pointer_bitrate_mode[0] = _bitrate_modes[bitrate_mode] + err = _snd.sf_command(self._file, _snd.SFC_SET_BITRATE_MODE, pointer_bitrate_mode, _ffi.sizeof(pointer_bitrate_mode)) + if err != _snd.SF_TRUE: + err = _snd.sf_error(self._file) + raise LibsndfileError(err, f"Error set bitrate mode {bitrate_mode}") + + + def set_compression_level(self, compression_level): + """Call libsndfile's set compression level function.""" + if not (0 <= compression_level <= 1): + raise ValueError("Compression level must be in range [0..1]") + + pointer_compression_level = _ffi.new("double[1]") + pointer_compression_level[0] = compression_level + err = _snd.sf_command(self._file, _snd.SFC_SET_COMPRESSION_LEVEL, pointer_compression_level, _ffi.sizeof(pointer_compression_level)) + if err != _snd.SF_TRUE: + err = _snd.sf_error(self._file) + raise LibsndfileError(err, f"Error set compression level {compression_level}") def _error_check(err, prefix=""): diff --git a/soundfile_build.py b/soundfile_build.py index 774fd9b..37d2de0 100644 --- a/soundfile_build.py +++ b/soundfile_build.py @@ -27,6 +27,9 @@ SFC_SET_SCALE_FLOAT_INT_READ = 0x1014, SFC_SET_SCALE_INT_FLOAT_WRITE = 0x1015, + + SFC_SET_COMPRESSION_LEVEL = 0x1301, + SFC_SET_BITRATE_MODE = 0x1305, } ; enum @@ -38,6 +41,11 @@ SFM_READ = 0x10, SFM_WRITE = 0x20, SFM_RDWR = 0x30, + + /* Modes for bitrate. */ + SF_BITRATE_MODE_CONSTANT = 0, + SF_BITRATE_MODE_AVERAGE = 1, + SF_BITRATE_MODE_VARIABLE = 2, } ; typedef int64_t sf_count_t ; diff --git a/tests/test_argspec.py b/tests/test_argspec.py index 97d985c..7775501 100644 --- a/tests/test_argspec.py +++ b/tests/test_argspec.py @@ -45,6 +45,10 @@ def test_write_defaults(): write_defaults = defaults(sf.write) init_defaults = defaults(sf.SoundFile.__init__) + # Only write values + del write_defaults['compression_level'] # compression_level is [0, 1] or None + del write_defaults['bitrate_mode'] # bitrate_mode is 'CONSTANT' or 'AVERAGE' or 'VARIABLE' or None + # Same default values as SoundFile.__init__() init_defaults = remove_items(init_defaults, write_defaults) diff --git a/tests/test_soundfile.py b/tests/test_soundfile.py index 07115af..70ee2cd 100644 --- a/tests/test_soundfile.py +++ b/tests/test_soundfile.py @@ -22,6 +22,8 @@ filename_mono = 'tests/mono.wav' filename_raw = 'tests/mono.raw' filename_new = 'tests/delme.please' +filename_mp3 = 'tests/stereo.mp3' +filename_flac = 'tests/stereo.flac' if sys.version_info >= (3, 6): @@ -295,6 +297,58 @@ def test_write_with_unknown_extension(filename): assert "file extension" in str(excinfo.value) +def test_write_mp3_compression(): + sr = 44100 + sf.write(filename_mp3, data_stereo, sr, format='MP3', subtype='MPEG_LAYER_III', + compression_level=0, bitrate_mode='CONSTANT') + constant_0_size = os.path.getsize(filename_mp3) + + sf.write(filename_mp3, data_stereo, sr, format='MP3', subtype='MPEG_LAYER_III', + compression_level=0, bitrate_mode='VARIABLE') + variable_0_size = os.path.getsize(filename_mp3) + assert variable_0_size < constant_0_size + + sf.write(filename_mp3, data_stereo, sr, format='MP3', subtype='MPEG_LAYER_III', + compression_level=0, bitrate_mode='AVERAGE') + average_0_size = os.path.getsize(filename_mp3) + assert (average_0_size < variable_0_size < constant_0_size) + + sf.write(filename_mp3, data_stereo, sr, format='MP3', subtype='MPEG_LAYER_III', + compression_level=0.999, bitrate_mode='CONSTANT') + constant_1_size= os.path.getsize(filename_mp3) + assert constant_1_size < constant_0_size + + sf.write(filename_mp3, data_stereo, sr, format='MP3', subtype='MPEG_LAYER_III', + compression_level=0.999, bitrate_mode='VARIABLE') + variable_1_size = os.path.getsize(filename_mp3) + assert constant_1_size Date: Wed, 20 Nov 2024 00:37:30 +0900 Subject: [PATCH 2/4] Move compression_level and bitrate_mode from write() arguments to parameter of SoundFile. --- soundfile.py | 68 ++++++++++++++++++++--------------------- tests/test_argspec.py | 9 +++--- tests/test_soundfile.py | 3 +- 3 files changed, 41 insertions(+), 39 deletions(-) diff --git a/soundfile.py b/soundfile.py index e60bc37..3a2d780 100644 --- a/soundfile.py +++ b/soundfile.py @@ -328,14 +328,9 @@ def write(file, data, samplerate, subtype=None, endian=None, format=None, Other Parameters ---------------- - format, endian, closefd + format, endian, closefd, compression_level, bitrate_mode See `SoundFile`. - compression_level : float, optional - See `libsndfile document `__. - bitrate_mode : {'CONSTANT', 'AVERAGE', 'VARIABLE'}, optional - See `libsndfile document `__. - Examples -------- Write 10 frames of random data to a new file: @@ -352,8 +347,9 @@ def write(file, data, samplerate, subtype=None, endian=None, format=None, else: channels = data.shape[1] with SoundFile(file, 'w', samplerate, channels, - subtype, endian, format, closefd) as f: - f.write(data, compression_level, bitrate_mode) + subtype, endian, format, closefd, + compression_level, bitrate_mode) as f: + f.write(data) def blocks(file, blocksize=None, overlap=0, frames=-1, start=0, stop=None, @@ -565,7 +561,8 @@ class SoundFile(object): """ def __init__(self, file, mode='r', samplerate=None, channels=None, - subtype=None, endian=None, format=None, closefd=True): + subtype=None, endian=None, format=None, closefd=True, + compression_level=None, bitrate_mode=None): """Open a sound file. If a file is opened with `mode` ``'r'`` (the default) or @@ -634,6 +631,14 @@ def __init__(self, file, mode='r', samplerate=None, channels=None, closefd : bool, optional Whether to close the file descriptor on `close()`. Only applicable if the *file* argument is a file descriptor. + compression_level : float, optional + The compression level on 'write()'. The compression level + should be between 0.0 (minimum compression level) and 1.0 + (highest compression level). + See `libsndfile document `__. + bitrate_mode : {'CONSTANT', 'AVERAGE', 'VARIABLE'}, optional + The bitrate mode on 'write()'. + See `libsndfile document `__. Examples -------- @@ -664,6 +669,8 @@ def __init__(self, file, mode='r', samplerate=None, channels=None, mode = getattr(file, 'mode', None) mode_int = _check_mode(mode) self._mode = mode + self._compression_level = compression_level + self._bitrate_mode = bitrate_mode self._info = _create_info_struct(file, mode, samplerate, channels, format, subtype, endian) self._file = self._open(file, mode_int, closefd) @@ -706,6 +713,10 @@ def __init__(self, file, mode='r', samplerate=None, channels=None, """Whether the sound file is closed or not.""" _errorcode = property(lambda self: _snd.sf_error(self._file)) """A pending sndfile error code.""" + compression_level = property(lambda self: self._compression_level) + """The compression level on 'write()'""" + bitrate_mode = property(lambda self: self._bitrate_mode) + """The bitrate mode on 'write()'""" @property def extra_info(self): @@ -722,7 +733,8 @@ def __repr__(self): return ("SoundFile({0.name!r}, mode={0.mode!r}, " "samplerate={0.samplerate}, channels={0.channels}, " "format={0.format!r}, subtype={0.subtype!r}, " - "endian={0.endian!r})".format(self)) + "endian={0.endian!r}, compression_level={0.compression_level}, " + "bitrate_mode={0.bitrate_mode})".format(self)) def __del__(self): self.close() @@ -979,7 +991,7 @@ def buffer_read_into(self, buffer, dtype): frames = self._cdata_io('read', cdata, ctype, frames) return frames - def write(self, data, compression_level=None, bitrate_mode=None): + def write(self, data): """Write audio data from a NumPy array to the file. Writes a number of frames at the read/write position to the @@ -1009,12 +1021,6 @@ def write(self, data, compression_level=None, bitrate_mode=None): file will then contain ``np.array([42.], dtype='float32')``. - Other Parameters - ---------------- - compression_level : float, optional - bitrate_mode : {'CONSTANT', 'AVERAGE', 'VARIABLE'}, optional - See `libsndfile document `__. - Examples -------- >>> import numpy as np @@ -1033,11 +1039,11 @@ def write(self, data, compression_level=None, bitrate_mode=None): """ import numpy as np - if compression_level is not None: + if self._compression_level is not None: # needs to be called before set_bitrate_mode - self.set_compression_level(compression_level) - if bitrate_mode is not None: - self.set_bitrate_mode(bitrate_mode) + self._set_compression_level(self._compression_level) + if self._bitrate_mode is not None: + self._set_bitrate_mode(self._bitrate_mode) # no copy is made if data has already the correct memory layout: data = np.ascontiguousarray(data) @@ -1045,7 +1051,7 @@ def write(self, data, compression_level=None, bitrate_mode=None): assert written == len(data) self._update_frames(written) - def buffer_write(self, data, dtype, compression_level=None, bitrate_mode=None): + def buffer_write(self, data, dtype): """Write audio data from a buffer/bytes object to the file. Writes the contents of *data* to the file at the current @@ -1061,22 +1067,16 @@ def buffer_write(self, data, dtype, compression_level=None, bitrate_mode=None): dtype : {'float64', 'float32', 'int32', 'int16'} The data type of the audio data stored in *data*. - Other Parameters - ---------------- - compression_level : float, optional - bitrate_mode : {'CONSTANT', 'AVERAGE', 'VARIABLE'}, optional - See `libsndfile document `__. - See Also -------- .write, buffer_read """ - if compression_level is not None: + if self._compression_level is not None: # needs to be called before set_bitrate_mode - self.set_compression_level(compression_level) - if bitrate_mode is not None: - self.set_bitrate_mode(bitrate_mode) + self._set_compression_level(self._compression_level) + if self._bitrate_mode is not None: + self._set_bitrate_mode(self._bitrate_mode) ctype = self._check_dtype(dtype) cdata, frames = self._check_buffer(data, ctype) @@ -1436,7 +1436,7 @@ def copy_metadata(self): strs[strtype] = _ffi.string(data).decode('utf-8', 'replace') return strs - def set_bitrate_mode(self, bitrate_mode): + def _set_bitrate_mode(self, bitrate_mode): """Call libsndfile's set bitrate mode function.""" assert bitrate_mode in _bitrate_modes @@ -1448,7 +1448,7 @@ def set_bitrate_mode(self, bitrate_mode): raise LibsndfileError(err, f"Error set bitrate mode {bitrate_mode}") - def set_compression_level(self, compression_level): + def _set_compression_level(self, compression_level): """Call libsndfile's set compression level function.""" if not (0 <= compression_level <= 1): raise ValueError("Compression level must be in range [0..1]") diff --git a/tests/test_argspec.py b/tests/test_argspec.py index 7775501..1a70088 100644 --- a/tests/test_argspec.py +++ b/tests/test_argspec.py @@ -30,6 +30,8 @@ def test_read_defaults(): init_defaults = defaults(sf.SoundFile.__init__) del init_defaults['mode'] # mode is always 'r' + del init_defaults['compression_level'] # only write() + del init_defaults['bitrate_mode'] # only write() del func_defaults['start'] del func_defaults['stop'] @@ -45,10 +47,6 @@ def test_write_defaults(): write_defaults = defaults(sf.write) init_defaults = defaults(sf.SoundFile.__init__) - # Only write values - del write_defaults['compression_level'] # compression_level is [0, 1] or None - del write_defaults['bitrate_mode'] # bitrate_mode is 'CONSTANT' or 'AVERAGE' or 'VARIABLE' or None - # Same default values as SoundFile.__init__() init_defaults = remove_items(init_defaults, write_defaults) @@ -63,6 +61,9 @@ def test_if_blocks_function_and_method_have_same_defaults(): meth_defaults = defaults(sf.SoundFile.blocks) init_defaults = defaults(sf.SoundFile.__init__) + del init_defaults['compression_level'] # only write() + del init_defaults['bitrate_mode'] # only write() + del func_defaults['start'] del func_defaults['stop'] del init_defaults['mode'] diff --git a/tests/test_soundfile.py b/tests/test_soundfile.py index 70ee2cd..05f91fa 100644 --- a/tests/test_soundfile.py +++ b/tests/test_soundfile.py @@ -674,7 +674,8 @@ def test__repr__(sf_stereo_r): assert repr(sf_stereo_r) == ("SoundFile({0.name!r}, mode='r', " "samplerate=44100, channels=2, " "format='WAV', subtype='FLOAT', " - "endian='FILE')").format(sf_stereo_r) + "endian='FILE', compression_level=None, " + "bitrate_mode=None)").format(sf_stereo_r) def test_extra_info(sf_stereo_r): From f89ac54e7110086f929c06720da32cfcb08b64ac Mon Sep 17 00:00:00 2001 From: ytya Date: Sat, 23 Nov 2024 15:25:42 +0900 Subject: [PATCH 3/4] calling set_compression_level and set_bitrate_mode move to constructor from write() --- soundfile.py | 26 ++++++++++++-------------- tests/test_soundfile.py | 12 ++++++++++-- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/soundfile.py b/soundfile.py index 3a2d780..31218b6 100644 --- a/soundfile.py +++ b/soundfile.py @@ -679,6 +679,13 @@ def __init__(self, file, mode='r', samplerate=None, channels=None, self.seek(0) _snd.sf_command(self._file, _snd.SFC_SET_CLIPPING, _ffi.NULL, _snd.SF_TRUE) + + # set compression setting + if self._compression_level is not None: + # needs to be called before set_bitrate_mode + self._set_compression_level(self._compression_level) + if self._bitrate_mode is not None: + self._set_bitrate_mode(self._bitrate_mode) name = property(lambda self: self._name) """The file name of the sound file.""" @@ -730,11 +737,14 @@ def extra_info(self): _file = None def __repr__(self): + compression_setting = (", compression_level={0}".format(self.compression_level) + if self.compression_level is not None else "") + compression_setting += (", bitrate_mode='{0}'".format(self.bitrate_mode) + if self.bitrate_mode is not None else "") return ("SoundFile({0.name!r}, mode={0.mode!r}, " "samplerate={0.samplerate}, channels={0.channels}, " "format={0.format!r}, subtype={0.subtype!r}, " - "endian={0.endian!r}, compression_level={0.compression_level}, " - "bitrate_mode={0.bitrate_mode})".format(self)) + "endian={0.endian!r}{1})".format(self, compression_setting)) def __del__(self): self.close() @@ -1039,12 +1049,6 @@ def write(self, data): """ import numpy as np - if self._compression_level is not None: - # needs to be called before set_bitrate_mode - self._set_compression_level(self._compression_level) - if self._bitrate_mode is not None: - self._set_bitrate_mode(self._bitrate_mode) - # no copy is made if data has already the correct memory layout: data = np.ascontiguousarray(data) written = self._array_io('write', data, len(data)) @@ -1072,12 +1076,6 @@ def buffer_write(self, data, dtype): .write, buffer_read """ - if self._compression_level is not None: - # needs to be called before set_bitrate_mode - self._set_compression_level(self._compression_level) - if self._bitrate_mode is not None: - self._set_bitrate_mode(self._bitrate_mode) - ctype = self._check_dtype(dtype) cdata, frames = self._check_buffer(data, ctype) written = self._cdata_io('write', cdata, ctype, frames) diff --git a/tests/test_soundfile.py b/tests/test_soundfile.py index 05f91fa..f6430f8 100644 --- a/tests/test_soundfile.py +++ b/tests/test_soundfile.py @@ -24,6 +24,7 @@ filename_new = 'tests/delme.please' filename_mp3 = 'tests/stereo.mp3' filename_flac = 'tests/stereo.flac' +filename_opus = 'tests/stereo.opus' if sys.version_info >= (3, 6): @@ -674,8 +675,15 @@ def test__repr__(sf_stereo_r): assert repr(sf_stereo_r) == ("SoundFile({0.name!r}, mode='r', " "samplerate=44100, channels=2, " "format='WAV', subtype='FLOAT', " - "endian='FILE', compression_level=None, " - "bitrate_mode=None)").format(sf_stereo_r) + "endian='FILE')").format(sf_stereo_r) + + sf_stereo_r._compression_level = 0 + sf_stereo_r._bitrate_mode = "CONSTANT" + assert repr(sf_stereo_r) == ("SoundFile({0.name!r}, mode='r', " + "samplerate=44100, channels=2, " + "format='WAV', subtype='FLOAT', " + "endian='FILE', compression_level=0, " + "bitrate_mode='CONSTANT')").format(sf_stereo_r) def test_extra_info(sf_stereo_r): From c311786eb79ddd52754382f9e7d8f3d996681543 Mon Sep 17 00:00:00 2001 From: ytya Date: Tue, 26 Nov 2024 00:36:05 +0900 Subject: [PATCH 4/4] Change np.concat to np.concatenate to fix test failure with numpy<2.0 --- tests/test_soundfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_soundfile.py b/tests/test_soundfile.py index f6430f8..fc0ea43 100644 --- a/tests/test_soundfile.py +++ b/tests/test_soundfile.py @@ -340,7 +340,7 @@ def test_write_flac_compression(): sr = 44100 # Compression requires a certain size data_stereo = np.random.random((sr, 1)) - data_stereo = np.concat([data_stereo, -data_stereo], axis=1) + data_stereo = np.concatenate([data_stereo, -data_stereo], axis=1) sf.write(filename_flac, data_stereo, sr, format='FLAC', subtype='PCM_16', compression_level=0) low_compression_size = os.path.getsize(filename_flac)