diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index d6406de..ddfa30c 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -29,6 +29,7 @@ jobs: run: | sudo apt-get update sudo apt-get install sox + sudo apt-get install libsox-fmt-mp3 python -m pip install --upgrade pip python -m pip install flake8 pytest pytest-cov python -m pip install coveralls @@ -47,7 +48,7 @@ jobs: - name: Test with pytest run: | pytest tests/ - + - name: Run Coveralls run: | coverage run -m pytest tests/ > ${{ github.workspace }}/coverage/lcov.info diff --git a/sox/transform.py b/sox/transform.py index 0559b64..79ea052 100644 --- a/sox/transform.py +++ b/sox/transform.py @@ -315,6 +315,7 @@ def _validate_output_format(self, output_format): bits = output_format.get('bits') channels = output_format.get('channels') encoding = output_format.get('encoding') + bitrate = output_format.get('bitrate') comments = output_format.get('comments') append_comments = output_format.get('append_comments', True) @@ -341,6 +342,9 @@ def _validate_output_format(self, output_format): if channels is not None and channels <= 0: raise ValueError('channels must be a positive number') + if not isinstance(bitrate, float) and bitrate is not None: + raise ValueError('bitrate must be a positive float or None') + if encoding not in ENCODING_VALS + [None]: raise ValueError( f'Invalid encoding. Must be one of {ENCODING_VALS}' @@ -362,6 +366,7 @@ def _output_format_args(self, output_format): bits = output_format.get('bits') channels = output_format.get('channels') encoding = output_format.get('encoding') + bitrate = output_format.get('bitrate') comments = output_format.get('comments') append_comments = output_format.get('append_comments', True) @@ -382,6 +387,9 @@ def _output_format_args(self, output_format): if encoding is not None: output_format_args.extend(['-e', f'{encoding}']) + if bitrate is not None: + output_format_args.extend(['-C', '{}'.format(bitrate)]) + if comments is not None: if append_comments: output_format_args.extend(['--add-comment', comments]) @@ -396,6 +404,7 @@ def set_output_format(self, bits: Optional[int] = None, channels: Optional[int] = None, encoding: Optional[EncodingValue] = None, + bitrate: Optional[float] = None, comments: Optional[str] = None, append_comments: bool = True): '''Sets output file format arguments. These arguments will overwrite @@ -454,6 +463,8 @@ def set_output_format(self, associated speech quality. SoX has support for GSM’s original 13kbps ‘Full Rate’ audio format. It is usually CPU-intensive to work with GSM audio. + bitrate : float, default=None + Desired bitrate. Uses Sox's -C (compression) argument. comments : str or None, default=None If not None, the string is added as a comment in the header of the output audio file. If None, no comments are added. @@ -467,6 +478,7 @@ def set_output_format(self, 'bits': bits, 'channels': channels, 'encoding': encoding, + 'bitrate': bitrate, 'comments': comments, 'append_comments': append_comments } @@ -1477,7 +1489,8 @@ def contrast(self, amount=75): def convert(self, samplerate: Optional[float] = None, n_channels: Optional[int] = None, - bitdepth: Optional[int] = None): + bitdepth: Optional[int] = None, + bitrate: Optional[float] = None): '''Converts output audio to the specified format. Parameters @@ -1488,6 +1501,8 @@ def convert(self, Desired number of channels. If None, defaults to the same as input. bitdepth : int, default=None Desired bitdepth. If None, defaults to the same as input. + bitrate : float, default=None + Desired bitrate. Uses Sox's -C (compression) argument. See Also -------- @@ -1511,6 +1526,11 @@ def convert(self, if not is_number(samplerate) or samplerate <= 0: raise ValueError("samplerate must be a positive number.") self.rate(samplerate) + if bitrate is not None: + if not isinstance(bitrate, float) or bitrate <= 0: + raise ValueError("bitrate must be a positive float.") + self.output_format["bitrate"] = bitrate + return self def dcshift(self, shift: float = 0.0): diff --git a/tests/data/output.mp3 b/tests/data/output.mp3 new file mode 100644 index 0000000..43c375a Binary files /dev/null and b/tests/data/output.mp3 differ diff --git a/tests/test_transform.py b/tests/test_transform.py index 14a99f8..e8f97d4 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -17,6 +17,7 @@ def relpath(f): INPUT_FILE4 = relpath('data/input4.wav') OUTPUT_FILE = relpath('data/output.wav') OUTPUT_FILE_ALT = relpath('data/output_alt.wav') +OUTPUT_FILE_MP3 = relpath('data/output.mp3') NOISE_PROF_FILE = relpath('data/noise.prof') @@ -371,6 +372,35 @@ def test_encoding_invalid(self): self.tfm.set_input_format(encoding='16-bit-signed-integer') with self.assertRaises(ValueError): self.tfm._input_format_args({'encoding': '16-bit-signed-integer'}) + def test_bitrate(self): + self.tfm.set_output_format(bitrate=320.0) + actual = self.tfm.output_format + expected = { + 'file_type': None, + 'rate': None, + 'bits': None, + 'channels': None, + 'encoding': None, + 'bitrate': 320.0, + 'comments': None, + 'append_comments': True + } + self.assertEqual(expected, actual) + + actual_args = self.tfm._output_format_args(self.tfm.output_format) + expected_args = ['-C', '320.0'] + self.assertEqual(expected_args, actual_args) + + actual_result = self.tfm.build(INPUT_FILE, OUTPUT_FILE) + expected_result = True + self.assertEqual(expected_result, actual_result) + + def test_bitrate_invalid(self): + with self.assertRaises(ValueError): + self.tfm.set_output_format(bitrate='320.0') + with self.assertRaises(ValueError): + self.tfm._output_format_args({'bitrate': 320}) + def test_ignore_length(self): self.tfm.set_input_format(ignore_length=True) @@ -427,6 +457,7 @@ def test_file_type(self): 'bits': None, 'channels': None, 'encoding': None, + 'bitrate': None, 'comments': None, 'append_comments': True } @@ -449,6 +480,7 @@ def test_file_type_null_output(self): 'bits': None, 'channels': None, 'encoding': None, + 'bitrate': None, 'comments': None, 'append_comments': True } @@ -477,6 +509,7 @@ def test_rate(self): 'bits': None, 'channels': None, 'encoding': None, + 'bitrate': None, 'comments': None, 'append_comments': True } @@ -499,6 +532,7 @@ def test_rate_scinotation(self): 'bits': None, 'channels': None, 'encoding': None, + 'bitrate': None, 'comments': None, 'append_comments': True } @@ -533,6 +567,7 @@ def test_bits(self): 'bits': 32, 'channels': None, 'encoding': None, + 'bitrate': None, 'comments': None, 'append_comments': True } @@ -567,6 +602,7 @@ def test_channels(self): 'bits': None, 'channels': 2, 'encoding': None, + 'bitrate': None, 'comments': None, 'append_comments': True } @@ -601,6 +637,7 @@ def test_encoding(self): 'bits': None, 'channels': None, 'encoding': 'signed-integer', + 'bitrate': None, 'comments': None, 'append_comments': True } @@ -629,6 +666,7 @@ def test_comments(self): 'bits': None, 'channels': None, 'encoding': None, + 'bitrate': None, 'comments': 'asdf', 'append_comments': True } @@ -657,6 +695,7 @@ def test_append_comments(self): 'bits': None, 'channels': None, 'encoding': None, + 'bitrate': None, 'comments': 'asdf', 'append_comments': False } @@ -1748,6 +1787,27 @@ def test_bitdepth_invalid(self): with self.assertRaises(ValueError): tfm.convert(bitdepth=17) + def test_bitrate_valid(self): + tfm = new_transformer() + tfm.convert(bitrate=320.0) + + actual = tfm.output_format + expected = {'bitrate': 320.0} + self.assertEqual(expected, actual) + + actual_res = tfm.build(INPUT_FILE, OUTPUT_FILE_MP3) + expected_res = True + self.assertEqual(expected_res, actual_res) + + tfm.set_output_format(file_type='mp3', bitrate=320.0) + tfm_assert_array_to_file_output( + INPUT_FILE, OUTPUT_FILE_MP3, tfm, skip_array_tests=True) + + def test_bitrate_invalid(self): + tfm = new_transformer() + with self.assertRaises(ValueError): + tfm.convert(bitrate=0) + class TestTransformerDcshift(unittest.TestCase):