Repository: lhotse-speech/lhotse

Issue:
[shar] cut can't load feature
when use
shards = train_cuts.to_shar(data_dir, fields={"features": "lilcom"}, shard_size=2000, num_jobs=20)
to make shar packages ;

one cut like:
MonoCut(id='X0000013171_273376986_S00164_sp1.1', start=0, duration=2.4818125, channel=0, supervisions=[SupervisionSegment(id='X0000013171_273376986_S00164_sp1.1', recording_id='X0000013171_273376986_sp1.1', start=0.0, duration=2.4818125, channel=0, text='用/花/里/胡/哨/的/甜/言/蜜/语/维/持/的/婚/姻', language='Chinese', speaker=None, gender=None, custom=None, alignment=None)], features=Features(type='kaldifeat-fbank', num_frames=248, num_features=80, frame_shift=0.01, sampling_rate=16000, start=425.6818125, duration=2.4818125, storage_type='memory_lilcom', storage_path='', storage_key='<binary-data>', recording_id='X0000013171_273376986_sp1.1', channels=0), recording=Recording(id='X0000013171_273376986_sp1.1', sources=[AudioSource(type='file', channels=[0], source='/store52/audio_data/WenetSpeech/audio/audio/train/podcast/B00051/X0000013171_273376986.opus')], sampling_rate=16000, num_samples=10926255, duration=682.8909375, channel_ids=[0], transforms=[{'name': 'Speed', 'kwargs': {'factor': 1.1}}]), custom={'dataloading_info': {'rank': 0, 'world_size': 1, 'worker_id': None}, 'shard_origin': PosixPath('cuts.020360.jsonl.gz'), 'shar_epoch': 0})

then try to load features :
cut.load_features()

has ERROR:
 ValueError: Cannot load features for recording X0000013171_273376986_sp1.1 starting from 0s. The available range is (425.6818125, 428.16362499999997) seconds.


Discussion:

Resolving patch:
diff --git a/lhotse/shar/utils.py b/lhotse/shar/utils.py
--- a/lhotse/shar/utils.py
+++ b/lhotse/shar/utils.py
@@ -11,14 +11,6 @@
 
 def to_shar_placeholder(manifest: Manifest, cut: Optional[Cut] = None) -> Manifest:
     if isinstance(manifest, Recording):
-        kwargs = (
-            {}
-            if cut is None
-            else dict(
-                duration=cut.duration,
-                num_samples=compute_num_samples(cut.duration, manifest.sampling_rate),
-            )
-        )
         return fastcopy(
             manifest,
             # Creates a single AudioSource out of multiple ones.
@@ -27,18 +19,35 @@ def to_shar_placeholder(manifest: Manifest, cut: Optional[Cut] = None) -> Manife
             ],
             # Removes the transform metadata because they were already executed.
             transforms=None,
-            **kwargs,
+            duration=cut.duration if cut is not None else manifest.duration,
+            num_samples=compute_num_samples(cut.duration, manifest.sampling_rate)
+            if cut is not None
+            else manifest.num_samples,
         )
-    # TODO: modify Features/TemporalArray's start/duration/num_frames if needed to match the Cut (in case we read subset of array)
-    elif isinstance(manifest, (Array, Features)):
+    elif isinstance(manifest, Array):
         return fastcopy(manifest, storage_type="shar", storage_path="", storage_key="")
+    elif isinstance(manifest, Features):
+        return fastcopy(
+            manifest,
+            start=0,
+            duration=cut.duration if cut is not None else manifest.duration,
+            storage_type="shar",
+            storage_path="",
+            storage_key="",
+        )
     elif isinstance(manifest, TemporalArray):
         return fastcopy(
             manifest,
+            start=0,
             array=fastcopy(
-                manifest.array, storage_type="shar", storage_path="", storage_key=""
+                manifest.array,
+                storage_type="shar",
+                storage_path="",
+                storage_key="",
             ),
         )
+    else:
+        raise RuntimeError(f"Unexpected manifest type: {type(manifest)}")
 
 
 def fill_shar_placeholder(


Test patch:
diff --git a/test/shar/test_write.py b/test/shar/test_write.py
--- a/test/shar/test_write.py
+++ b/test/shar/test_write.py
@@ -9,7 +9,7 @@
 from lhotse.audio.backend import audio_backend, check_torchaudio_version_gt
 from lhotse.lazy import LazyJsonlIterator
 from lhotse.shar import AudioTarWriter, SharWriter, TarIterator, TarWriter
-from lhotse.testing.dummies import DummyManifest
+from lhotse.testing.dummies import DummyManifest, dummy_cut
 
 
 def test_tar_writer(tmp_path: Path):
@@ -687,3 +687,49 @@ def test_shar_writer_pipe(tmp_path: Path):
         assert cut.custom_recording.sources[0].type == "shar"
         with pytest.raises(RuntimeError):
             cut.load_custom_recording()
+
+
+def test_shar_writer_truncates_temporal_array_and_features(tmp_path: Path):
+    # Basic data and sanity check of shapes.
+    cut = dummy_cut(0, with_data=True)
+    for k in "custom_embedding custom_features custom_recording".split():
+        cut = cut.drop_custom(k)
+    ref_audio = cut.load_audio()
+    ref_feats = cut.load_features()
+    ref_indxs = cut.load_custom_indexes()
+    assert ref_audio.shape == (1, 16000)
+    assert ref_feats.shape == (100, 23)
+    assert ref_indxs.shape == (100,)
+
+    # Truncated cut before writing to Shar and sanity check of shapes and content.
+    cut = cut.truncate(offset=0.2, duration=0.6)
+    trunc_audio = cut.load_audio()
+    trunc_feats = cut.load_features()
+    trunc_indxs = cut.load_custom_indexes()
+    assert trunc_audio.shape == (1, 9600)
+    np.testing.assert_array_equal(trunc_audio, ref_audio[:, 3200:-3200])
+    assert trunc_feats.shape == (60, 23)
+    np.testing.assert_array_equal(trunc_feats, ref_feats[20:-20, :])
+    assert trunc_indxs.shape == (60,)
+    np.testing.assert_array_equal(trunc_indxs, ref_indxs[20:-20])
+
+    # System under test.
+    with SharWriter(
+        tmp_path,
+        fields={"recording": "wav", "features": "numpy", "custom_indexes": "numpy"},
+        shard_size=None,
+    ) as writer:
+        writer.write(cut)
+
+    # Truncated cut restored from Shar and sanity check of shapes and content.
+    sharcuts = CutSet.from_shar(in_dir=writer.output_dir)
+    cut = sharcuts[0]
+    trunc_audio = cut.load_audio()
+    trunc_feats = cut.load_features()
+    trunc_indxs = cut.load_custom_indexes()
+    assert trunc_audio.shape == (1, 9600)
+    np.testing.assert_array_equal(trunc_audio, ref_audio[:, 3200:-3200])
+    assert trunc_feats.shape == (60, 23)
+    np.testing.assert_array_equal(trunc_feats, ref_feats[20:-20, :])
+    assert trunc_indxs.shape == (60,)
+    np.testing.assert_array_equal(trunc_indxs, ref_indxs[20:-20])