Repository: lhotse-speech/lhotse

Issue:
comment
would be nice to have a comment here mentioning that DummySet contains everything.

https://github.com/pzelasko/lhotse/blob/7555df605def57836c9454ae44aac95c504d86b0/lhotse/audio.py#L77


Discussion:

Resolving patch:
diff --git a/lhotse/audio.py b/lhotse/audio.py
--- a/lhotse/audio.py
+++ b/lhotse/audio.py
@@ -1,4 +1,6 @@
 from dataclasses import dataclass, asdict
+from dataclasses import dataclass, asdict
+from io import BytesIO
 from subprocess import run, PIPE
 from typing import List, Optional, Dict, Union
 
@@ -6,7 +8,7 @@
 import numpy as np
 import yaml
 
-from lhotse.utils import Pathlike, INT16MAX, DummySet
+from lhotse.utils import Pathlike, SetContainingAnything
 
 Channels = Union[int, List[int]]
 
@@ -16,9 +18,8 @@ class AudioSource:
     """
     AudioSource represents audio data that can be retrieved from somewhere.
     Supported sources of audio are currently:
-    - a file (possibly multi-channel)
-    - a command/unix pipe (single-channel only)
-    - a collection of any of the above (see AudioSourceCollection)
+    - a file (formats supported by librosa, possibly multi-channel)
+    - a command/unix pipe (must be WAVE, possibly multi-channel)
     """
     type: str
     channel_ids: List[int]
@@ -29,24 +30,31 @@ def load_audio(
             offset_seconds: float = 0.0,
             duration_seconds: Optional[float] = None
     ) -> np.ndarray:
+        """
+        Load the AudioSource (both files and commands) with librosa,
+        accounting for many audio formats and multi-channel inputs.
+        Returns numpy array with shapes: (n_samples) for single-channel,
+        (n_channels, n_samples) for multi-channel.
+        """
         assert self.type in ('file', 'command')
 
-        if self.type == 'file':
-            # TODO(pzelasko): make sure that librosa loads multi-channel audio
-            #                 in the expected format (n_channels, n_samples)
-            return librosa.load(
-                self.source,
-                sr=None,  # 'None' uses the native sampling rate
-                offset=offset_seconds,
-                duration=duration_seconds
-            )[0]  # discard returned sampling rate
+        if self.type == 'command':
+            if offset_seconds != 0.0 or duration_seconds is not None:
+                # TODO(pzelasko): How should we support chunking for commands?
+                #                 We risk being very inefficient when reading many chunks from the same file
+                #                 without some caching scheme, because we'll be re-running commands.
+                raise ValueError("Reading audio chunks from command AudioSource type is currently not supported.")
+            source = BytesIO(run(self.source, shell=True, stdout=PIPE).stdout)
+        else:
+            source = self.source
 
-        # TODO(pzelasko): the following naively assumes we're dealing with raw PCM...
-        #                 not sure if that's how we should do it
-        #                 also, how should we support chunking for commands?
-        raw_audio = run(self.source, shell=True, stdout=PIPE).stdout
-        int16_audio = np.frombuffer(raw_audio, dtype=np.int16)
-        return int16_audio / INT16MAX
+        return librosa.load(
+            source,
+            sr=None,  # 'None' uses the native sampling rate
+            mono=False,  # Retain multi-channel if it's there
+            offset=offset_seconds,
+            duration=duration_seconds
+        )[0]  # discard returned sampling rate
 
 
 @dataclass
@@ -74,7 +82,7 @@ def load_audio(
             duration_seconds: Optional[float] = None
     ) -> np.ndarray:
         if channels is None:
-            channels = DummySet()
+            channels = SetContainingAnything()
         elif isinstance(channels, int):
             channels = frozenset([channels])
         else:
diff --git a/lhotse/utils.py b/lhotse/utils.py
--- a/lhotse/utils.py
+++ b/lhotse/utils.py
@@ -6,7 +6,7 @@
 INT16MAX = 32768
 
 
-class DummySet:
+class SetContainingAnything:
     def __contains__(self, item):
         return True
 


Test patch:
diff --git a/test/fixtures/audio.yaml b/test/fixtures/audio.yaml
--- a/test/fixtures/audio.yaml
+++ b/test/fixtures/audio.yaml
@@ -6,7 +6,16 @@
   sources:
     - type: file
       channel_ids: [0]
-      source: 'test/fixtures/dummy.wav'
+      source: 'test/fixtures/mono_c0.wav'
     - type: command
       channel_ids: [1]
-      source: 'cat test/fixtures/dummy.pcm | cat'  # test whether pipes work
+      source: 'cat test/fixtures/mono_c1.wav | cat'  # test whether pipes work
+
+- id: 'recording-2'
+  sampling_rate: 8000
+  num_samples: 8000
+  duration_seconds: 1.0
+  sources:
+    - type: file
+      channel_ids: [0, 1]
+      source: 'test/fixtures/stereo.wav'
diff --git a/test/fixtures/dummy.wav b/test/fixtures/mono_c0.wav
similarity index 100%
rename from test/fixtures/dummy.wav
rename to test/fixtures/mono_c0.wav
diff --git a/test/fixtures/dummy.pcm b/test/fixtures/mono_c1.wav
similarity index 97%
rename from test/fixtures/dummy.pcm
rename to test/fixtures/mono_c1.wav
Binary files a/test/fixtures/dummy.pcm and b/test/fixtures/mono_c1.wav differ
diff --git a/test/fixtures/stereo.wav b/test/fixtures/stereo.wav
new file mode 100644
Binary files /dev/null and b/test/fixtures/stereo.wav differ
diff --git a/test/test_audio_set.py b/test/test_audio_set.py
--- a/test/test_audio_set.py
+++ b/test/test_audio_set.py
@@ -14,22 +14,34 @@ def get_audio_set() -> AudioSet:
 
 @lru_cache(1)
 def expected_channel_0() -> np.ndarray:
+    """Contents of test/fixtures/mono_c0.wav"""
     return np.arange(0, 4000) / INT16MAX
 
 
 @lru_cache(1)
 def expected_channel_1() -> np.ndarray:
+    """Contents of test/fixtures/mono_c1.wav"""
     return np.arange(4000, 8000) / INT16MAX
 
 
 @lru_cache(1)
-def expected_stereo() -> np.ndarray:
+def expected_stereo_two_sources() -> np.ndarray:
+    """Combined contents of test/fixtures/mono_c{0,1}.wav as channels 0 and 1"""
     return np.vstack([
         expected_channel_0(),
         expected_channel_1()
     ])
 
 
+@lru_cache(1)
+def expected_stereo_single_source() -> np.ndarray:
+    """Contents of test/fixtures/stereo.wav"""
+    return np.vstack([
+        np.arange(8000, 16000, dtype=np.int16),
+        np.arange(16000, 24000, dtype=np.int16)
+    ]) / INT16MAX
+
+
 def test_get_metadata():
     audio_set = get_audio_set()
     assert 2 == audio_set.num_channels('recording-1')
@@ -46,12 +58,12 @@ def test_serialization():
                 AudioSource(
                     type='file',
                     channel_ids=[0],
-                    source='text/fixtures/dummy.wav'
+                    source='text/fixtures/mono_c0.wav'
                 ),
                 AudioSource(
                     type='command',
                     channel_ids=[1],
-                    source='cat text/fixtures/dummy.wav'
+                    source='cat text/fixtures/mono_c1.wav'
                 )
             ],
             sampling_rate=8000,
@@ -72,16 +84,22 @@ def test_iteration():
 def test_get_audio_from_multiple_files():
     audio_set = get_audio_set()
     samples = audio_set.load_audio('recording-1')
-    np.testing.assert_almost_equal(samples, expected_stereo())
+    np.testing.assert_almost_equal(samples, expected_stereo_two_sources())
+
+
+def test_get_stereo_audio_from_single_file():
+    audio_set = get_audio_set()
+    samples = audio_set.load_audio('recording-2')
+    np.testing.assert_almost_equal(samples, expected_stereo_single_source())
 
 
 @mark.parametrize(
     ['channels', 'expected_audio'],
     [
-        (None, expected_stereo()),
+        (None, expected_stereo_two_sources()),
         (0, expected_channel_0()),
         (1, expected_channel_1()),
-        ([0, 1], expected_stereo()),
+        ([0, 1], expected_stereo_two_sources()),
         param(1000, 'irrelevant', marks=mark.xfail)
     ]
 )
diff --git a/test/test_text_supervision_set.py b/test/test_text_supervision_set.py
--- a/test/test_text_supervision_set.py
+++ b/test/test_text_supervision_set.py
@@ -2,5 +2,6 @@
 
 
 def test_text_supervision_set():
+    return
     text_set = TextSupervisionSet()
     text_set.get_utterance('utt_id')