Skip to content

Commit 2420065

Browse files
Add generic audio processing pipeline for ASR and TTS data preparation (#1679)
* Update pyptoject.toml Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Add generic audio tagging pipeline Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update configs and benchmarking scripts * Rename files and use common get duration method Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Fix formatting Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Fix minor bugs Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update random usage in pyannote.py Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update get duration method Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Fix minor issues Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Add inputs and outputs methods to all stages Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Fix ruff check Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update scripts Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Fix bug prepare segments Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update scripts Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * AudioBatch to AudioTask migration Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Remove unwanted stages Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Remove metric stages Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update scripts Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Remove unused packages Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Fix minor bugs Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Add tts e2e test and specify key paramenters in stages Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Fix extra whitespace and remove unwanted fixtures Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update typehints for setup calls Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update readme Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Enhance benchmark logs Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Remove cuda parameter Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Remove ALM reader writer scripts and update scripts Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Add log metrics Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Add soundfile import in duration stage Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update tutorial Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update lock file Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update lock file Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Remove default fields Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Install ffmpeg and sox in CI/CD Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Fix audio tests Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Fix synthetic cpu ci tests Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Add xenna spec to pyannote Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update pyannote.py Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Fix tests Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Reduce test batch size Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Reduce cpu percentage usage Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Use batch mode for e2e test Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Cap vllm version Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Revert few changes Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Test in streaming mode Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Merge with main and fix test scripts Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Undo changes in test_vllm.py Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update lock file Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update lock file Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update test_common.py Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Mark few gpu tests Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Add sox installation to dockerfile Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update scripts Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Remove upper bounds for torch libraries Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Add torchcodec constraints Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Keep upper bounds on torch overrides Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Revert deleted files Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Remove sox dependency Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Fix import Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Update lock file Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Make top level import Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> * Remove torch upper bound Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> --------- Signed-off-by: Sushmitha Deva <sdeva@nvidia.com> Co-authored-by: Sarah Yurick <53962159+sarahyurick@users.noreply.github.com>
1 parent 378e169 commit 2420065

74 files changed

Lines changed: 5201 additions & 1242 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/cicd-main.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ jobs:
8686
sudo rm -rf /opt/ghc
8787
sudo rm -rf /usr/local/share/boost
8888
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
89+
- name: Install system dependencies for audio tests
90+
if: matrix.folder == 'stages-audio'
91+
run: |
92+
sudo apt-get update
93+
sudo apt-get install -y --no-install-recommends ffmpeg
8994
- name: Install uv
9095
uses: astral-sh/setup-uv@v6
9196
with:

benchmarking/ALM_BENCHMARK.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ A dedicated benchmark for measuring the performance of the ALM (Audio Language M
55
## How It Works
66

77
The benchmark script:
8-
1. Loads a JSONL manifest via `ALMManifestReader` (CompositeStage: FilePartitioningStage + ALMManifestReaderStage)
8+
1. Loads a JSONL manifest via `ManifestReader` (CompositeStage: FilePartitioningStage + ManifestReaderStage)
99
2. Optionally multiplies entries with `--repeat-factor` via `_RepeatEntriesStage`
1010
3. Runs `ALMDataBuilderStage` (windowing) + `ALMDataOverlapStage` (filtering)
1111
4. Executes through XennaExecutor, RayDataExecutor, or RayActorPoolExecutor
@@ -214,7 +214,7 @@ Results from running on a single workstation:
214214
| Throughput (entries/sec) | 108.08 |
215215
| Throughput (windows/sec) | 3,912.36 |
216216

217-
The `repeat-factor` multiplies entries in-memory after reading (via `_RepeatEntriesStage`), so the manifest file is read only once. The pipeline scales well with XennaExecutor auto-allocating workers per stage via the CompositeStage reader (FilePartitioningStage + ALMManifestReaderStage).
217+
The `repeat-factor` multiplies entries in-memory after reading (via `RepeatEntriesStage`), so the manifest file is read only once. The pipeline scales well with XennaExecutor auto-allocating workers per stage via the CompositeStage reader (FilePartitioningStage + ManifestReaderStage).
218218

219219
## Output Files
220220

benchmarking/nightly-benchmark.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,30 @@ entries:
986986
ray:
987987
num_gpus: 0
988988

989+
- name: audio_tagging_tts
990+
enabled: true
991+
script: audio_tagging_benchmark.py
992+
args: >-
993+
--benchmark-results-path={session_entry_dir}
994+
--input-manifest={session_entry_dir}/input.jsonl
995+
--hf-token=${HF_SECRET_KEY}
996+
--max-segment-length=40
997+
--asr-batch-size=50
998+
--executor=xenna
999+
--cpus=10
1000+
timeout_s: 3600
1001+
sink_data:
1002+
- name: slack
1003+
ping_on_failure:
1004+
- U06J49AK7BQ # Sushmitha Deva
1005+
ray:
1006+
num_cpus: 16
1007+
num_gpus: 1
1008+
enable_object_spilling: false
1009+
requirements:
1010+
- metric: is_success
1011+
exact_value: true
1012+
9891013
- name: interleaved_filter_xenna
9901014
enabled: false
9911015
script: interleaved_filter_benchmark.py

benchmarking/scripts/alm_pipeline_benchmark.py

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -32,38 +32,14 @@
3232

3333
import yaml
3434
from loguru import logger
35-
from utils import setup_executor, write_benchmark_results
35+
from utils import RepeatEntriesStage, setup_executor, write_benchmark_results
3636

3737
from nemo_curator.pipeline import Pipeline
38+
from nemo_curator.stages.audio import ManifestReader
3839
from nemo_curator.stages.audio.alm import (
3940
ALMDataBuilderStage,
4041
ALMDataOverlapStage,
41-
ALMManifestReader,
4242
)
43-
from nemo_curator.stages.base import ProcessingStage
44-
from nemo_curator.tasks import AudioTask
45-
46-
47-
class _RepeatEntriesStage(ProcessingStage[AudioTask, AudioTask]):
48-
"""Multiply each AudioTask N times for scale testing.
49-
50-
Duplicates entries in-memory after reading so the file is only read once.
51-
"""
52-
53-
name = "repeat_entries"
54-
55-
def __init__(self, repeat_factor: int = 1) -> None:
56-
self._repeat_factor = repeat_factor
57-
58-
def process(self, task: AudioTask) -> list[AudioTask]:
59-
return [
60-
AudioTask(
61-
data=task.data.copy(),
62-
_metadata=task._metadata,
63-
_stage_perf=list(task._stage_perf),
64-
)
65-
for _ in range(self._repeat_factor)
66-
]
6743

6844

6945
def run_alm_pipeline_benchmark( # noqa: PLR0913, PLR0915
@@ -93,9 +69,9 @@ def run_alm_pipeline_benchmark( # noqa: PLR0913, PLR0915
9369
logger.info(f"Overlap percentage: {overlap_percentage}")
9470

9571
pipeline = Pipeline(name="alm_benchmark", description="ALM Reader + Builder + Overlap benchmark pipeline")
96-
pipeline.add_stage(ALMManifestReader(manifest_path=input_manifest))
72+
pipeline.add_stage(ManifestReader(manifest_path=input_manifest))
9773
if repeat_factor > 1:
98-
pipeline.add_stage(_RepeatEntriesStage(repeat_factor=repeat_factor))
74+
pipeline.add_stage(RepeatEntriesStage(repeat_factor=repeat_factor))
9975
logger.info(f"Repeat factor: {repeat_factor}x (entries multiplied after reading)")
10076
pipeline.add_stage(
10177
ALMDataBuilderStage(

benchmarking/scripts/audio_fleurs_benchmark.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from nemo_curator.pipeline import Pipeline
3131
from nemo_curator.stages.audio.common import GetAudioDurationStage, PreserveByValueStage
3232
from nemo_curator.stages.audio.datasets.fleurs.create_initial_manifest import CreateInitialManifestFleursStage
33-
from nemo_curator.stages.audio.inference.asr_nemo import InferenceAsrNemoStage
33+
from nemo_curator.stages.audio.inference.asr.asr_nemo import InferenceAsrNemoStage
3434
from nemo_curator.stages.audio.io.convert import AudioToDocumentStage
3535
from nemo_curator.stages.audio.metrics.get_wer import GetPairwiseWerStage
3636
from nemo_curator.stages.resources import Resources

benchmarking/scripts/audio_sortformer_benchmark.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
from utils import setup_executor, write_benchmark_results
2929

3030
from nemo_curator.pipeline import Pipeline
31-
from nemo_curator.stages.audio.alm.alm_manifest_reader import ALMManifestReader
31+
from nemo_curator.stages.audio import ManifestReader
3232
from nemo_curator.stages.audio.inference.sortformer import InferenceSortformerStage
3333

3434

@@ -77,7 +77,7 @@ def run_audio_sortformer_benchmark(
7777
description="Streaming Sortformer speaker diarization inference.",
7878
)
7979

80-
pipeline.add_stage(ALMManifestReader(manifest_path=manifest_path))
80+
pipeline.add_stage(ManifestReader(manifest_path=manifest_path))
8181
pipeline.add_stage(
8282
InferenceSortformerStage(
8383
model_name=model_name,
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Audio tagging pipeline benchmarking script.
16+
17+
Runs the core audio tagging pipeline end-to-end:
18+
ManifestReader -> Resample -> Diarize -> Split -> ASR Align ->
19+
Join -> Merge -> Write
20+
21+
Exercises the core stages of the tagging pipeline for regression tracking.
22+
"""
23+
24+
import argparse
25+
import time
26+
from pathlib import Path
27+
from typing import Any
28+
29+
from loguru import logger
30+
from utils import RepeatEntriesStage, setup_executor, write_benchmark_results
31+
32+
from nemo_curator.pipeline import Pipeline
33+
from nemo_curator.stages.audio.common import ManifestReader, ManifestWriterStage
34+
from nemo_curator.stages.audio.inference.speaker_diarization.pyannote import PyAnnoteDiarizationStage
35+
from nemo_curator.stages.audio.tagging.inference.nemo_asr_align import NeMoASRAlignerStage
36+
from nemo_curator.stages.audio.tagging.merge_alignment_diarization import MergeAlignmentDiarizationStage
37+
from nemo_curator.stages.audio.tagging.resample_audio import ResampleAudioStage
38+
from nemo_curator.stages.audio.tagging.split import JoinSplitAudioMetadataStage, SplitLongAudioStage
39+
from nemo_curator.stages.resources import Resources
40+
41+
42+
def run_audio_tagging_benchmark( # noqa: PLR0913
43+
benchmark_results_path: str,
44+
input_manifest: str,
45+
repeat_factor: int,
46+
hf_token: str,
47+
max_segment_length: float,
48+
asr_batch_size: int,
49+
executor: str,
50+
cpus: int,
51+
**kwargs, # noqa: ARG001
52+
) -> dict[str, Any]:
53+
"""Run the full audio tagging pipeline benchmark."""
54+
benchmark_results_path = Path(benchmark_results_path)
55+
results_dir = benchmark_results_path / "results"
56+
57+
resampled_audio_dir = str(benchmark_results_path / "audio_resampled")
58+
final_manifest = str(results_dir / "tagging_output.jsonl")
59+
60+
logger.info("Starting audio tagging pipeline benchmark")
61+
logger.info(f"CPUs: {cpus}")
62+
logger.info(f"Max segment length: {max_segment_length}s")
63+
64+
exc = setup_executor(executor, config={"execution_mode": "streaming"})
65+
run_start_time = time.perf_counter()
66+
67+
pipeline = Pipeline(
68+
name="audio_tagging_benchmark",
69+
description="Audio tagging core benchmark: FLEURS -> core tagging pipeline",
70+
)
71+
72+
pipeline.add_stage(ManifestReader(manifest_path=input_manifest))
73+
if repeat_factor > 1:
74+
pipeline.add_stage(RepeatEntriesStage(repeat_factor=repeat_factor))
75+
logger.info(f"Repeat factor: {repeat_factor}x (entries multiplied after reading from manifest)")
76+
77+
# Resample audio to 16 kHz mono WAV
78+
pipeline.add_stage(
79+
ResampleAudioStage(
80+
resampled_audio_dir=resampled_audio_dir,
81+
input_format="wav",
82+
target_sample_rate=16000,
83+
target_format="wav",
84+
target_nchannels=1,
85+
).with_(resources=Resources(cpus=cpus))
86+
)
87+
88+
# Speaker diarization and overlap detection (PyAnnote)
89+
pipeline.add_stage(
90+
PyAnnoteDiarizationStage(
91+
name="PyAnnoteDiarization",
92+
hf_token=hf_token,
93+
max_length=max_segment_length,
94+
).with_(resources=Resources(cpus=cpus, gpus=0.5))
95+
)
96+
97+
# Split long audio segments
98+
pipeline.add_stage(
99+
SplitLongAudioStage(
100+
name="SplitLongAudio",
101+
suggested_max_len=max_segment_length,
102+
min_len=1.0,
103+
).with_(resources=Resources(cpus=cpus))
104+
)
105+
106+
# ASR forced alignment (NeMo FastConformer)
107+
pipeline.add_stage(
108+
NeMoASRAlignerStage(
109+
name="ASRAlignment",
110+
is_fastconformer=True,
111+
decoder_type="rnnt",
112+
batch_size=asr_batch_size,
113+
).with_(resources=Resources(cpus=cpus, gpus=0.45))
114+
)
115+
116+
# Rejoin split audio metadata
117+
pipeline.add_stage(JoinSplitAudioMetadataStage(name="JoinSplitMetadata").with_(resources=Resources(cpus=cpus)))
118+
119+
# Merge alignment with diarization
120+
pipeline.add_stage(
121+
MergeAlignmentDiarizationStage(
122+
name="MergeAlignmentDiar",
123+
text_key="text",
124+
words_key="words",
125+
).with_(resources=Resources(cpus=cpus))
126+
)
127+
128+
# Write output manifest
129+
pipeline.add_stage(ManifestWriterStage(output_path=final_manifest).with_(resources=Resources(cpus=cpus)))
130+
131+
results = pipeline.run(exc)
132+
133+
run_time_taken = time.perf_counter() - run_start_time
134+
135+
total_duration = sum(task.data["duration"] for task in results) / 3600
136+
137+
logger.success("Audio tagging benchmark completed successfully!!")
138+
logger.success(f"Processed {len(results)} tasks")
139+
logger.success(f"Total audio duration processed: {total_duration:.2f} hours")
140+
logger.success(f"Throughput: {len(results) / run_time_taken:.2f} tasks per second")
141+
logger.success(f"Total time taken: {run_time_taken / 60:.2f} minutes")
142+
143+
return {
144+
"metrics": {
145+
"is_success": True,
146+
"time_taken_s": run_time_taken,
147+
"num_tasks_processed": len(results),
148+
"throughput_tasks_per_sec": len(results) / run_time_taken if run_time_taken > 0 else 0,
149+
"total_audio_duration_hours": total_duration,
150+
},
151+
"tasks": results,
152+
}
153+
154+
155+
def main() -> int:
156+
parser = argparse.ArgumentParser(
157+
description="Audio tagging pipeline e2e benchmark (FLEURS -> full tagging pipeline)"
158+
)
159+
parser.add_argument("--input-manifest", required=True, help="Path to input manifest")
160+
parser.add_argument("--repeat-factor", type=int, default=1, help="Repeat factor for the input manifest entries")
161+
parser.add_argument("--benchmark-results-path", required=True, help="Path to write benchmark results")
162+
parser.add_argument("--hf-token", default="", help="HuggingFace token for PyAnnote")
163+
parser.add_argument(
164+
"--max-segment-length", type=float, default=40.0, help="Maximum segment duration (seconds) to infer ASR"
165+
)
166+
parser.add_argument("--asr-batch-size", type=int, default=100, help="Batch size for ASR alignment")
167+
parser.add_argument("--executor", default="xenna", choices=["xenna", "ray_data", "ray_actors"], help="Executor")
168+
parser.add_argument("--cpus", type=int, default=10, help="Number of CPUs to use for the pipeline")
169+
170+
args = parser.parse_args()
171+
172+
logger.info("=== Audio Tagging Pipeline Benchmark Starting ===")
173+
logger.info(f"Arguments: {vars(args)}")
174+
175+
success_code = 1
176+
177+
result_dict: dict[str, Any] = {
178+
"params": vars(args),
179+
"metrics": {"is_success": False},
180+
"tasks": [],
181+
}
182+
try:
183+
result_dict.update(run_audio_tagging_benchmark(**vars(args)))
184+
success_code = 0 if result_dict["metrics"]["is_success"] else 1
185+
finally:
186+
write_benchmark_results(result_dict, args.benchmark_results_path)
187+
return success_code
188+
189+
190+
if __name__ == "__main__":
191+
raise SystemExit(main())

benchmarking/scripts/utils.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,19 @@
2828
from nemo_curator.backends.ray_actor_pool import RayActorPoolExecutor
2929
from nemo_curator.backends.ray_data import RayDataExecutor
3030
from nemo_curator.backends.xenna import XennaExecutor
31+
from nemo_curator.stages.base import ProcessingStage
32+
from nemo_curator.tasks import AudioTask
3133
from nemo_curator.utils.file_utils import get_all_file_paths_and_size_under
3234

3335
_executor_map = {"ray_data": RayDataExecutor, "xenna": XennaExecutor, "ray_actors": RayActorPoolExecutor}
3436

3537

36-
def setup_executor(executor_name: str) -> RayDataExecutor | XennaExecutor | RayActorPoolExecutor:
38+
def setup_executor(
39+
executor_name: str, config: dict[str, Any] | None = None
40+
) -> RayDataExecutor | XennaExecutor | RayActorPoolExecutor:
3741
"""Setup the executor for the given name."""
3842
try:
39-
executor = _executor_map[executor_name]()
43+
executor = _executor_map[executor_name](config=config)
4044
except KeyError:
4145
msg = f"Executor {executor_name} not supported"
4246
raise ValueError(msg) from None
@@ -380,3 +384,25 @@ def convert_paths_to_strings(obj: object) -> object:
380384
else:
381385
retval = obj
382386
return retval
387+
388+
389+
class RepeatEntriesStage(ProcessingStage[AudioTask, AudioTask]):
390+
"""Multiply each AudioTask N times for scale testing.
391+
392+
Duplicates entries in-memory after reading so the file is only read once.
393+
"""
394+
395+
name = "repeat_entries"
396+
397+
def __init__(self, repeat_factor: int = 1) -> None:
398+
self._repeat_factor = repeat_factor
399+
400+
def process(self, task: AudioTask) -> list[AudioTask]:
401+
return [
402+
AudioTask(
403+
data=task.data.copy(),
404+
_metadata=task._metadata,
405+
_stage_perf=list(task._stage_perf),
406+
)
407+
for _ in range(self._repeat_factor)
408+
]

0 commit comments

Comments
 (0)