Repository: audeering/audiofile

Issue:
Windows tests fail for MP3 files
For unknown reasons we are not able to make `ffmpeg` work for MP3 files inside the Windows tests.
It fails when trying to convert the file, e.g. https://github.com/audeering/audiofile/pull/44/checks?check_run_id=3251320851

For other non SND files like MP4 converting works.

Discussion:

Patch:
diff --git a/audiofile/core/convert.py b/audiofile/core/convert.py
index 4ed5b91..314f42d 100644
--- a/audiofile/core/convert.py
+++ b/audiofile/core/convert.py
@@ -36,7 +36,8 @@ def convert_to_wav(
         offset: start reading at offset in seconds
 
     Raises:
-        RuntimeError: if ``file`` is broken or not a supported format
+        RuntimeError: if ``file`` is missing,
+            broken or format is not supported
 
     """
     infile = audeer.safe_path(infile)
@@ -50,3 +51,5 @@ def convert_to_wav(
             run_ffmpeg(infile, outfile, offset, duration)
         except subprocess.CalledProcessError:
             raise RuntimeError(broken_file_error(infile))
+    except OSError:
+        raise RuntimeError(broken_file_error(infile))
diff --git a/audiofile/core/info.py b/audiofile/core/info.py
index 987b380..8e0e068 100644
--- a/audiofile/core/info.py
+++ b/audiofile/core/info.py
@@ -35,7 +35,8 @@ def bit_depth(file: str) -> typing.Optional[int]:
         bit depth of audio file
 
     Raises:
-        RuntimeError: if ``file`` is broken or not a supported format
+        RuntimeError: if ``file`` is missing,
+            broken or format is not supported
 
     """
     file = audeer.safe_path(file)
@@ -80,7 +81,8 @@ def channels(file: str) -> int:
         number of channels in audio file
 
     Raises:
-        RuntimeError: if ``file`` is broken or not a supported format
+        RuntimeError: if ``file`` is missing,
+            broken or format is not supported
 
     """
     file = audeer.safe_path(file)
@@ -100,6 +102,8 @@ def channels(file: str) -> int:
                     return int(run(cmd2))
                 except ValueError:
                     raise RuntimeError(broken_file_error(file))
+        except OSError:
+            raise RuntimeError(broken_file_error(file))
 
 
 def duration(file: str, sloppy=False) -> float:
@@ -133,7 +137,8 @@ def duration(file: str, sloppy=False) -> float:
         duration in seconds of audio file
 
     Raises:
-        RuntimeError: if ``file`` is broken or not a supported format
+        RuntimeError: if ``file`` is missing,
+            broken or format is not supported
 
     """
     file = audeer.safe_path(file)
@@ -153,7 +158,8 @@ def duration(file: str, sloppy=False) -> float:
             if duration:
                 # Convert to seconds, as mediainfo returns milliseconds
                 return float(duration) / 1000
-
+        except OSError:
+            raise RuntimeError(broken_file_error(file))
     return samples(file) / sampling_rate(file)
 
 
@@ -167,7 +173,8 @@ def samples(file: str) -> int:
         number of samples in audio file
 
     Raises:
-        RuntimeError: if ``file`` is broken or not a supported format
+        RuntimeError: if ``file`` is missing,
+            broken or format is not supported
 
     """
     def samples_as_int(file):
@@ -196,7 +203,8 @@ def sampling_rate(file: str) -> int:
         sampling rate of audio file
 
     Raises:
-        RuntimeError: if ``file`` is broken or not a supported format
+        RuntimeError: if ``file`` is missing,
+            broken or format is not supported
 
     """
     file = audeer.safe_path(file)
@@ -212,3 +220,5 @@ def sampling_rate(file: str) -> int:
                 return int(sampling_rate)
             else:
                 raise RuntimeError(broken_file_error(file))
+        except OSError:
+            raise RuntimeError(broken_file_error(file))
diff --git a/audiofile/core/io.py b/audiofile/core/io.py
index ed9188b..1405ddf 100644
--- a/audiofile/core/io.py
+++ b/audiofile/core/io.py
@@ -56,7 +56,8 @@ def read(
         * sample rate of the audio file
 
     Raises:
-        RuntimeError: if ``file`` is broken or not a supported format
+        RuntimeError: if ``file`` is missing,
+            broken or format is not supported
 
     """
     file = audeer.safe_path(file)


Test patch:
diff --git a/tests/test_audiofile.py b/tests/test_audiofile.py
index 971e18a..925e1c4 100644
--- a/tests/test_audiofile.py
+++ b/tests/test_audiofile.py
@@ -58,6 +58,21 @@ def non_audio_file(tmpdir, request):
         os.remove(broken_file)
 
 
+def convert_to_mp3(infile, outfile, sampling_rate, channels):
+    """Convert file to MP3 using ffmpeg."""
+    subprocess.call(
+        [
+            'ffmpeg',
+            '-i', infile,
+            '-vn',
+            '-ar', str(sampling_rate),
+            '-ac', str(channels),
+            '-b:a', '192k',
+            outfile,
+        ]
+    )
+
+
 def tolerance(condition, sampling_rate=0):
     """Absolute tolerance for different condition."""
     tol = 0
@@ -141,12 +156,20 @@ def test_read(tmpdir, duration, offset):
     assert fs == sampling_rate
 
 
+@pytest.mark.parametrize(
+    'convert',
+    (True, False),
+)
 @pytest.mark.parametrize(
     'empty_file',
     ('bin', 'mp3', 'wav'),
     indirect=True,
 )
-def test_empty_file(empty_file):
+def test_empty_file(tmpdir, convert, empty_file):
+    if convert:
+        converted_file = str(tmpdir.join('signal-converted.wav'))
+        af.convert_to_wav(empty_file, converted_file)
+        empty_file = converted_file
     # Reading file
     signal, sampling_rate = af.read(empty_file)
     assert len(signal) == 0
@@ -163,21 +186,28 @@ def test_empty_file(empty_file):
 
 
 @pytest.mark.parametrize(
-    'ext, expected_error',
-    [
-        ('bin', OSError),
-        ('mp3', OSError),
-        ('wav', RuntimeError),
-    ],
+    'ext',
+    ('bin', 'mp3', 'wav'),
 )
-def test_missing_file(ext, expected_error):
+def test_missing_file(tmpdir, ext):
     missing_file = f'missing_file.{ext}'
+    expected_error = RuntimeError
     # Reading file
     with pytest.raises(expected_error):
         signal, sampling_rate = af.read(missing_file)
     # Metadata
     with pytest.raises(expected_error):
         af.sampling_rate(missing_file)
+    with pytest.raises(expected_error):
+        af.channels(missing_file)
+    with pytest.raises(expected_error):
+        af.duration(missing_file)
+    with pytest.raises(expected_error):
+        af.duration(missing_file, sloppy=True)
+    # Convert
+    with pytest.raises(expected_error):
+        converted_file = str(tmpdir.join('signal-converted.wav'))
+        af.convert_to_wav(missing_file, converted_file)
 
 
 @pytest.mark.parametrize(
@@ -185,27 +215,34 @@ def test_missing_file(ext, expected_error):
     ('bin', 'mp3', 'wav'),
     indirect=True,
 )
-def test_broken_file(non_audio_file):
+def test_broken_file(tmpdir, non_audio_file):
     # Only match the beginning of error message
     # as the default soundfile message differs at the end on macOS
     error_msg = 'Error opening'
+    expected_error = RuntimeError
     # Reading file
-    with pytest.raises(RuntimeError, match=error_msg):
+    with pytest.raises(expected_error, match=error_msg):
         af.read(non_audio_file)
     # Metadata
     if audeer.file_extension(non_audio_file) == 'wav':
-        with pytest.raises(RuntimeError, match=error_msg):
+        with pytest.raises(expected_error, match=error_msg):
             af.bit_depth(non_audio_file)
     else:
         assert af.bit_depth(non_audio_file) is None
-    with pytest.raises(RuntimeError, match=error_msg):
+    with pytest.raises(expected_error, match=error_msg):
         af.channels(non_audio_file)
-    with pytest.raises(RuntimeError, match=error_msg):
+    with pytest.raises(expected_error, match=error_msg):
         af.duration(non_audio_file)
-    with pytest.raises(RuntimeError, match=error_msg):
+    with pytest.raises(expected_error, match=error_msg):
+        af.duration(non_audio_file, sloppy=True)
+    with pytest.raises(expected_error, match=error_msg):
         af.samples(non_audio_file)
-    with pytest.raises(RuntimeError, match=error_msg):
+    with pytest.raises(expected_error, match=error_msg):
         af.sampling_rate(non_audio_file)
+    # Convert
+    with pytest.raises(expected_error, match=error_msg):
+        converted_file = str(tmpdir.join('signal-converted.wav'))
+        af.convert_to_wav(non_audio_file, converted_file)
 
 
 @pytest.mark.parametrize("bit_depth", [8, 16, 24, 32])
@@ -336,11 +373,6 @@ def test_file_type(tmpdir, file_type, magnitude, sampling_rate, channels):
 @pytest.mark.parametrize('magnitude', [0.01])
 def test_mp3(tmpdir, magnitude, sampling_rate, channels):
 
-    # Currently we are not able to setup the Windows runner with MP3 support
-    # https://github.com/audeering/audiofile/issues/51
-    if sys.platform == 'win32':
-        return
-
     signal = sine(magnitude=magnitude,
                   sampling_rate=sampling_rate,
                   channels=channels)
@@ -348,11 +380,9 @@ def test_mp3(tmpdir, magnitude, sampling_rate, channels):
     wav_file = str(tmpdir.join('signal.wav'))
     mp3_file = str(tmpdir.join('signal.mp3'))
     af.write(wav_file, signal, sampling_rate)
-    subprocess.call(['sox', wav_file, mp3_file])
+    convert_to_mp3(wav_file, mp3_file, sampling_rate, channels)
     assert audeer.file_extension(mp3_file) == 'mp3'
     sig, fs = af.read(mp3_file)
-    assert_allclose(_magnitude(sig), magnitude,
-                    rtol=0, atol=tolerance(16))
     assert fs == sampling_rate
     assert _channels(sig) == channels
     if channels == 1:
@@ -363,8 +393,11 @@ def test_mp3(tmpdir, magnitude, sampling_rate, channels):
     assert af.sampling_rate(mp3_file) == sampling_rate
     assert af.samples(mp3_file) == _samples(sig)
     assert af.duration(mp3_file) == _duration(sig, sampling_rate)
-    assert af.duration(mp3_file, sloppy=True) == sox.file_info.duration(
-        mp3_file
+    assert_allclose(
+        af.duration(mp3_file, sloppy=True),
+        _duration(sig, sampling_rate),
+        rtol=0,
+        atol=0.2,
     )
     assert af.bit_depth(mp3_file) is None