Repository: lhotse-speech/lhotse

Issue:
Support reading from `reco2dur` in `lhotse.kaldi.load_kaldi_data_dir`
For now in `lhotse.kaldi.load_kaldi_data_dir`, it reads the original audio file, and get the duration info. but this is not friendly to long recodings with `sox` or `ffmpeg` conversion, when `reco2dur` already exists in the kaldi data directory.

Will you support loading `reco2dur` and get duration information from this file?


Discussion:
We initially supported `reco2dur` but unfortunately it was not precise enough for the durations and we were running into issues with mismatched manifest metadata and audio that was loaded from file/command. I see the following options:
1) modify Kaldi's `reco2dur` to have precise duration information (`num_samples / sampling_rate` without truncation after 2 decimal points IIRC) - I don't know if this would break anything else or not though.
2) since that time, Lhotse supports setting a tolerance threshold for duration mismatch between audio and manifests, we could technically support reading imprecise `reco2dur` and the user could increase the mismatch threshold if necessary. But I think it could be confusing and not the right thing to do in general.
Make sense. So for option 1 we still have to recalculate the precise duration. I would try the option 2. I guess it's fairly safe to set tolerance threshold to 0.01 s as this is usually what the frame_shift is.
If you can contribute the relevant option for `load_kaldi_data_dir` (disabled by default, enabled via argument/flag) in Lhotse I'd be happy to merge that PR.

Resolving patch:
diff --git a/lhotse/bin/modes/kaldi.py b/lhotse/bin/modes/kaldi.py
--- a/lhotse/bin/modes/kaldi.py
+++ b/lhotse/bin/modes/kaldi.py
@@ -39,6 +39,15 @@ def kaldi():
     type=int,
     help="Number of jobs for computing recording durations.",
 )
+@click.option(
+    "-d",
+    "--compute-durations",
+    default=False,
+    is_flag=True,
+    show_default=True,
+    type=bool,
+    help="Compute durations by reading the whole file instead of using reco2dur file",
+)
 def import_(
     data_dir: Pathlike,
     sampling_rate: int,
@@ -46,6 +55,7 @@ def import_(
     frame_shift: float,
     map_string_to_underscores: Optional[str],
     num_jobs: int,
+    compute_durations: bool,
 ):
     """
     Convert a Kaldi data dir DATA_DIR into a directory MANIFEST_DIR of lhotse manifests. Ignores feats.scp.
@@ -59,6 +69,7 @@ def import_(
         frame_shift=frame_shift,
         map_string_to_underscores=map_string_to_underscores,
         num_jobs=num_jobs,
+        use_reco2dur=not compute_durations,
     )
     manifest_dir = Path(manifest_dir)
     manifest_dir.mkdir(parents=True, exist_ok=True)
diff --git a/lhotse/kaldi.py b/lhotse/kaldi.py
--- a/lhotse/kaldi.py
+++ b/lhotse/kaldi.py
@@ -55,12 +55,15 @@ def load_kaldi_data_dir(
     sampling_rate: int,
     frame_shift: Optional[Seconds] = None,
     map_string_to_underscores: Optional[str] = None,
+    use_reco2dur: bool = True,
     num_jobs: int = 1,
 ) -> Tuple[RecordingSet, Optional[SupervisionSet], Optional[FeatureSet]]:
     """
     Load a Kaldi data directory and convert it to a Lhotse RecordingSet and
     SupervisionSet manifests. For this to work, at least the wav.scp file must exist.
-    SupervisionSet is created only when a segments file exists.
+    SupervisionSet is created only when a segments file exists. reco2dur is used by
+    default when exists (to enforce reading the duration from the audio files
+    themselves, please set use_reco2dur = False.
     All the other files (text, utt2spk, etc.) are optional, and some of them might
     not be handled yet. In particular, feats.scp files are ignored.
 
@@ -79,10 +82,17 @@ def fix_id(t: str) -> str:
 
     # must exist for RecordingSet
     recordings = load_kaldi_text_mapping(path / "wav.scp", must_exist=True)
-
-    with ProcessPoolExecutor(num_jobs) as ex:
-        dur_vals = ex.map(get_duration, recordings.values())
-    durations = dict(zip(recordings.keys(), dur_vals))
+    reco2dur = path / "reco2dur"
+    if use_reco2dur and reco2dur.is_file():
+        durations = load_kaldi_text_mapping(reco2dur, float_vals=True)
+        assert len(durations) == len(recordings), (
+            "The duration file reco2dur does not "
+            "have the same length as the  wav.scp file"
+        )
+    else:
+        with ProcessPoolExecutor(num_jobs) as ex:
+            dur_vals = ex.map(get_duration, recordings.values())
+        durations = dict(zip(recordings.keys(), dur_vals))
 
     recording_set = RecordingSet.from_recordings(
         Recording(
@@ -327,13 +337,15 @@ def export_to_kaldi(
 
 
 def load_kaldi_text_mapping(
-    path: Path, must_exist: bool = False
+    path: Path, must_exist: bool = False, float_vals: bool = False
 ) -> Dict[str, Optional[str]]:
     """Load Kaldi files such as utt2spk, spk2gender, text, etc. as a dict."""
     mapping = defaultdict(lambda: None)
     if path.is_file():
         with path.open() as f:
             mapping = dict(line.strip().split(maxsplit=1) for line in f)
+        if float_vals:
+            mapping = {key: float(val) for key, val in mapping.items()}
     elif must_exist:
         raise ValueError(f"No such file: {path}")
     return mapping
@@ -371,18 +383,21 @@ def make_wavscp_channel_string_map(
             # used in the sph files
             audios = dict()
             for channel in source.channels:
-                audios[
-                    channel
-                ] = f"sph2pipe {source.source} -f wav -c {channel+1} -p | ffmpeg -threads 1 -i pipe:0 -ar {sampling_rate} -f wav -threads 1 pipe:1 |"
+                audios[channel] = (
+                    f"sph2pipe {source.source} -f wav -c {channel+1} -p | "
+                    "ffmpeg -threads 1"
+                    f" -i pipe:0 -ar {sampling_rate} -f wav -threads 1 pipe:1 |"
+                )
 
             return audios
         else:
             # Handles non-WAVE audio formats and multi-channel WAVEs.
             audios = dict()
             for channel in source.channels:
-                audios[
-                    channel
-                ] = f"ffmpeg -threads 1 -i {source.source} -ar {sampling_rate} -map_channel 0.0.{channel}  -f wav -threads 1 pipe:1 |"
+                audios[channel] = (
+                    f"ffmpeg -threads 1 -i {source.source} -ar {sampling_rate} "
+                    f"-map_channel 0.0.{channel}  -f wav -threads 1 pipe:1 |"
+                )
             return audios
 
     else:


Test patch:
diff --git a/test/fixtures/mini_librispeech/reco2dur b/test/fixtures/mini_librispeech/reco2dur
new file mode 100644
--- /dev/null
+++ b/test/fixtures/mini_librispeech/reco2dur
@@ -0,0 +1,38 @@
+lbi-1272-135031-0000 10.885
+lbi-1272-141231-0000 4.650
+lbi-1462-170142-0000 4.715
+lbi-1462-170145-0000 15.404999732971191
+lbi-174-168635-0000 4.53000020980835
+lbi-1988-147956-0000 14.949999809265137
+lbi-1988-24833-0000 3.319999933242798
+lbi-1993-147964-0000 8.420000076293945
+lbi-2035-147960-0000 9.020000457763672
+lbi-2035-147961-0000 15.069999694824219
+lbi-2035-152373-0000 7.875
+lbi-2412-153948-0000 11.65999984741211
+lbi-2428-83699-0000 13.305000305175781
+lbi-251-118436-0000 6.260000228881836
+lbi-251-136532-0000 9.8100004196167
+lbi-2803-154320-0000 11.479999542236328
+lbi-2803-161169-0000 11.699999809265137
+lbi-3000-15664-0000 3.130000114440918
+lbi-3536-23268-0000 19.889999389648438
+lbi-3576-138058-0000 14.140000343322754
+lbi-3752-4944-0000 3.3350000381469727
+lbi-5338-24640-0000 3.450000047683716
+lbi-5338-284437-0000 4.550000190734863
+lbi-5694-64038-0000 2.5950000286102295
+lbi-5895-34615-0000 3.3350000381469727
+lbi-5895-34622-0000 3.369999885559082
+lbi-5895-34629-0000 2.259999990463257
+lbi-6241-61943-0000 6.949999809265137
+lbi-6241-61946-0000 6.235000133514404
+lbi-6295-244435-0000 3.1050000190734863
+lbi-6319-57405-0000 7.295000076293945
+lbi-777-126732-0000 2.740000009536743
+lbi-7850-281318-0000 4.175000190734863
+lbi-7850-286674-0000 8.454999923706055
+lbi-7976-110523-0000 15.220000267028809
+lbi-8297-275156-0000 3.5799999237060547
+lbi-84-121550-0000 8.4350004196167
+lbi-8842-304647-0000 9.710000038146973
diff --git a/test/test_kaldi_dirs.py b/test/test_kaldi_dirs.py
--- a/test/test_kaldi_dirs.py
+++ b/test/test_kaldi_dirs.py
@@ -112,6 +112,20 @@ def multi_channel_recording():
     return recording, supervision
 
 
+@pytest.fixture
+def multi_channel_kaldi_dir():
+    return {
+        "wav.scp": {
+            "lbi-1272-135031-0000_0": "ffmpeg -threads 1 -i nonexistent.wav -ar 16000 -map_channel 0.0.0  -f wav -threads 1 pipe:1 |",
+            "lbi-1272-135031-0000_1": "ffmpeg -threads 1 -i nonexistent.wav -ar 16000 -map_channel 0.0.1  -f wav -threads 1 pipe:1 |",
+        },
+        "segments": {
+            "lbi-1272-135031-0000-A": "lbi-1272-135031-0000_0 0.0 10.885",
+            "lbi-1272-135031-0000-B": "lbi-1272-135031-0000_1 0.0 10.885",
+        },
+    }
+
+
 @pytest.mark.xfail(reason="multi file recordings not supported yet")
 def test_multi_file_recording(tmp_path, multi_file_recording):
     with working_directory(tmp_path):
@@ -124,7 +138,9 @@ def test_multi_file_recording(tmp_path, multi_file_recording):
         )
 
 
-def test_multi_channel_recording(tmp_path, multi_channel_recording):
+def test_multi_channel_recording(
+    tmp_path, multi_channel_recording, multi_channel_kaldi_dir
+):
     with working_directory(tmp_path):
         lhotse.kaldi.export_to_kaldi(
             multi_channel_recording[0],
@@ -134,6 +150,11 @@ def test_multi_channel_recording(tmp_path, multi_channel_recording):
             prefix_spk_id=False,
         )
 
+        wavs = open_and_load("wav.scp")
+        segments = open_and_load("segments")
+        assert wavs == multi_channel_kaldi_dir["wav.scp"]
+        assert segments == multi_channel_kaldi_dir["segments"]
+
 
 @contextlib.contextmanager
 def working_directory(path):
@@ -156,6 +177,7 @@ def test_kaldi_import(replace):
             frame_shift=0.01,
             map_string_to_underscores=replace,
             num_jobs=1,
+            use_reco2dur=False,
         )
 
     lhotse_dir = "lhotse"
@@ -166,7 +188,7 @@ def test_kaldi_import(replace):
         recording_set = lhotse.RecordingSet.from_jsonl("recordings.jsonl.gz")
         supervision_set = lhotse.SupervisionSet.from_jsonl("supervisions.jsonl.gz")
 
-    assert out[0] == recording_set
+    assert list(out[0]) == list(recording_set)
     assert out[1] == supervision_set
 
 
@@ -220,6 +242,7 @@ def test_ok_on_file_singlechannel_sph_source_type(tmp_path, channel):
     assert list(out.keys()) == [channel]
     assert out[channel].startswith("sph2pipe")
     assert "nonexistent.sph" in out[channel]
+    assert "{" not in out[channel]
 
 
 @pytest.mark.parametrize("channel", [0, 3])
@@ -231,6 +254,7 @@ def test_ok_on_file_singlechannel_mp3_source_type(tmp_path, channel):
     assert list(out.keys()) == [channel]
     assert out[channel].startswith("ffmpeg")
     assert "nonexistent.mp3" in out[channel]
+    assert "{" not in out[channel]
 
 
 def test_ok_on_file_multichannel_wav_source_type(tmp_path):
@@ -242,6 +266,7 @@ def test_ok_on_file_multichannel_wav_source_type(tmp_path):
     for channel in out.keys():
         assert out[channel].startswith("ffmpeg")
         assert "nonexistent.wav" in out[channel]
+        assert "{" not in out[channel]
 
 
 def test_load_kaldi_text_mapping(tmp_path):
@@ -249,6 +274,35 @@ def test_load_kaldi_text_mapping(tmp_path):
         lhotse.kaldi.load_kaldi_text_mapping(tmp_path / "nonexistent", must_exist=True)
 
 
+@pytest.mark.parametrize("load_durations", [False, True])
+def test_load_durations(tmp_path, load_durations):
+    fixture_path = MINILIB_PATH
+    with working_directory(fixture_path):
+        out = lhotse.kaldi.load_kaldi_data_dir(
+            fixture_path,
+            sampling_rate=16000,
+            frame_shift=0.01,
+            use_reco2dur=load_durations,
+            num_jobs=1,
+        )
+
+    lhotse_dir = "lhotse"
+    with working_directory(fixture_path / lhotse_dir):
+        recording_set = lhotse.RecordingSet.from_jsonl("recordings.jsonl.gz")
+        supervision_set = lhotse.SupervisionSet.from_jsonl("supervisions.jsonl.gz")
+
+    if load_durations:
+        for i, recording in enumerate(out[0]):
+            assert recording_set[i].duration == pytest.approx(
+                recording.duration,
+                lhotse.audio.LHOTSE_AUDIO_DURATION_MISMATCH_TOLERANCE,
+            )
+    else:
+        assert list(out[0]) == list(recording_set)
+    assert out[1] == supervision_set
+    pass
+
+
 @pytest.mark.parametrize("replace", [None, "b"])
 @pytest.mark.parametrize("prefix", [False, True])
 def test_kaldi_export(tmp_path, replace, prefix):
@@ -297,6 +351,7 @@ def test_kaldi_export(tmp_path, replace, prefix):
     assert len(wavs.keys()) == len(wavs_orig.keys())
     for elem in wavs_orig.keys():
         assert wavs_orig[elem].rstrip(" |") == wavs[elem].rstrip(" |")
+        assert "{" not in wavs[elem]
 
     for elem in segments_orig.keys():
         elem_other = utt2spk_orig[elem] + "-" + elem if prefix else elem