chore: import upstream snapshot with attribution
CICD NeMo / cicd-main-unit-tests (push) Blocked by required conditions
CICD NeMo / cicd-main-speech (push) Blocked by required conditions
CICD NeMo / cicd-test-container-build (push) Blocked by required conditions
CICD NeMo / cicd-import-tests (push) Blocked by required conditions
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Blocked by required conditions
CICD NeMo / Nemo_CICD_Test (push) Blocked by required conditions
CICD NeMo / Coverage (e2e) (push) Blocked by required conditions
CICD NeMo / Coverage (unit-test) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
CICD NeMo / cicd-wait-in-queue (push) Waiting to run
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
CICD NeMo / cicd-main-unit-tests (push) Blocked by required conditions
CICD NeMo / cicd-main-speech (push) Blocked by required conditions
CICD NeMo / cicd-test-container-build (push) Blocked by required conditions
CICD NeMo / cicd-import-tests (push) Blocked by required conditions
CICD NeMo / L0_Setup_Test_Data_And_Models (push) Blocked by required conditions
CICD NeMo / Nemo_CICD_Test (push) Blocked by required conditions
CICD NeMo / Coverage (e2e) (push) Blocked by required conditions
CICD NeMo / Coverage (unit-test) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Create PR to main with cherry-pick from release / cherry-pick (push) Failing after 0s
CICD NeMo / pre-flight (push) Failing after 0s
CICD NeMo / configure (push) Has been skipped
Build, validate, and release Neural Modules / pre-flight (push) Failing after 1s
CICD NeMo / code-linting (push) Has been skipped
CICD NeMo / cicd-wait-in-queue (push) Waiting to run
Build, validate, and release Neural Modules / release (push) Has been skipped
Build, validate, and release Neural Modules / release-summary (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
import torch
|
||||
from omegaconf import MISSING
|
||||
from torch.nn.utils.rnn import pad_sequence
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.collections.asr.parts.context_biasing.boosting_graph_batched import (
|
||||
BoostingTreeModelConfig,
|
||||
GPUBoostingTreeModel,
|
||||
)
|
||||
from nemo.collections.common.tokenizers import AggregateTokenizer
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildWordBoostingTreeConfig(BoostingTreeModelConfig):
|
||||
"""
|
||||
Build GPU-accelerated phrase boosting tree (btree) to be used with greedy and beam search decoders of ASR models.
|
||||
"""
|
||||
|
||||
asr_pretrained_name: Optional[str] = None # Name of a pretrained model
|
||||
asr_model_path: Optional[str] = None # The path to '.nemo' ASR checkpoint
|
||||
save_to: str = MISSING # The path to save the GPU-accelerated word boosting graph
|
||||
|
||||
# evaluation of obtained boosting tree with test_sentences (optional)
|
||||
test_boosting_tree: bool = False # Whether to test the GPU-accelerated word boosting tree after building it
|
||||
test_sentences: List[str] = field(
|
||||
default_factory=list
|
||||
) # The phrases to test boosting tree ["hello world","nvlink","nvlinz","omniverse cloud now","acupuncture"]
|
||||
|
||||
|
||||
@hydra_runner(config_path=None, config_name='BuildWordBoostingTreeConfig', schema=BuildWordBoostingTreeConfig)
|
||||
def main(cfg: BuildWordBoostingTreeConfig):
|
||||
|
||||
# 1. load asr model to obtain tokenizer
|
||||
if cfg.asr_model_path is None and cfg.asr_pretrained_name is None:
|
||||
raise ValueError("Either asr_model_path or asr_pretrained_name must be provided")
|
||||
elif cfg.asr_model_path is not None:
|
||||
asr_model = nemo_asr.models.ASRModel.restore_from(cfg.asr_model_path, map_location=torch.device('cpu'))
|
||||
else:
|
||||
asr_model = nemo_asr.models.ASRModel.from_pretrained(cfg.asr_pretrained_name)
|
||||
|
||||
is_aggregate_tokenizer = isinstance(asr_model.tokenizer, AggregateTokenizer)
|
||||
|
||||
# 2. Build GPU-accelerated word boosting tree from config
|
||||
gpu_boosting_model = GPUBoostingTreeModel.from_config(cfg, tokenizer=asr_model.tokenizer)
|
||||
|
||||
# 3. save gpu boosting tree to nemo file
|
||||
gpu_boosting_model.save_to(cfg.save_to)
|
||||
|
||||
# 4. test gpu boosting tree model
|
||||
logging.info("testing gpu boosting tree model...")
|
||||
if cfg.test_boosting_tree and cfg.test_sentences:
|
||||
gpu_boosting_model_loaded = GPUBoostingTreeModel.from_nemo(
|
||||
cfg.save_to, vocab_size=len(asr_model.tokenizer.vocab), use_triton=cfg.use_triton
|
||||
)
|
||||
device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
|
||||
gpu_boosting_model_loaded = gpu_boosting_model_loaded.cuda()
|
||||
|
||||
if not is_aggregate_tokenizer:
|
||||
sentences_ids = [asr_model.tokenizer.text_to_ids(sentence) for sentence in cfg.test_sentences]
|
||||
sentences_tokens = [asr_model.tokenizer.text_to_tokens(sentence) for sentence in cfg.test_sentences]
|
||||
else:
|
||||
sentences_ids = [
|
||||
asr_model.tokenizer.text_to_ids(sentence, cfg.source_lang) for sentence in cfg.test_sentences
|
||||
]
|
||||
sentences_tokens = [] # aggregate tokenizer does not support text_to_tokens
|
||||
|
||||
boosting_scores = gpu_boosting_model_loaded(
|
||||
labels=pad_sequence([torch.LongTensor(sentence) for sentence in sentences_ids], batch_first=True).to(
|
||||
device
|
||||
),
|
||||
labels_lengths=torch.LongTensor([len(sentence) for sentence in sentences_ids]).to(device),
|
||||
bos=False,
|
||||
eos=False if not is_aggregate_tokenizer else True,
|
||||
)
|
||||
|
||||
logging.info(f"[info]: boosting_scores: {boosting_scores}")
|
||||
logging.info(f"[info]: test_sentences: {cfg.test_sentences}")
|
||||
logging.info(f"[info]: test_sentences_tokens: {sentences_tokens}")
|
||||
logging.info(f"[info]: test_sentences_ids: {sentences_ids}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,50 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
from nemo.collections.asr.parts import context_biasing
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--input_manifest",
|
||||
type=str,
|
||||
required=True,
|
||||
help="manifest with recognition results",
|
||||
)
|
||||
parser.add_argument("--key_words_file", type=str, required=True, help="file of key words for fscore calculation")
|
||||
parser.add_argument(
|
||||
"--ctcws-mode",
|
||||
action='store_true',
|
||||
help="whether to use ctcws mode to split the key words from transcriptions",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
key_words_list = []
|
||||
with open(args.key_words_file, encoding='utf-8') as f:
|
||||
for line in f.readlines():
|
||||
if args.ctcws_mode:
|
||||
item = line.strip().split("_")[0].lower()
|
||||
else:
|
||||
item = line.strip().lower()
|
||||
if item not in key_words_list:
|
||||
key_words_list.append(item)
|
||||
|
||||
context_biasing.compute_fscore(args.input_manifest, key_words_list, print_stats=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,511 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""
|
||||
# This script evaluates CTC and Transducer (RNNT) models (only Hybrid Transducer-CTC in case of Transducer) in context biasing mode
|
||||
# by applying CTC-based Word Spotter (paper link)
|
||||
|
||||
# Config Help
|
||||
|
||||
To discover all arguments of the script, please run :
|
||||
python eval_greedy_decoding_with_context_biasing.py --help
|
||||
python eval_greedy_decoding_with_context_biasing.py --cfg job
|
||||
|
||||
# USAGE
|
||||
|
||||
python eval_greedy_decoding_with_context_biasing.py \
|
||||
nemo_model_file=<path to the .nemo file of the model> \
|
||||
input_manifest=<path to the evaluation JSON manifest file \
|
||||
preds_output_folder=<folder to store the predictions> \
|
||||
decoder_type=<type of model decoder [ctc or rnnt]> \
|
||||
acoustic_batch_size=<batch size to calculate log probabilities> \
|
||||
apply_context_biasing=<True or False to apply context biasing> \
|
||||
context_file=<path to the context biasing file with key words/phrases> \
|
||||
beam_threshold=[<list of the beam thresholds, separated with commas>] \
|
||||
context_score=[<list of the context scores, separated with commas>] \
|
||||
ctc_ali_token_weight=[<list of the ctc alignment token weights, separated with commas>] \
|
||||
...
|
||||
|
||||
# Description of context biasing graph:
|
||||
Context biasing file contains words/phrases with their spellings
|
||||
(one word/phrase per line, spellings are separated from word/phrase by underscore symbol):
|
||||
WORD1_SPELLING1
|
||||
WORD2_SPELLING1_SPELLING2
|
||||
...
|
||||
nvidia_nvidia
|
||||
gpu_gpu_g p u
|
||||
nvlink_nvlink_nv link
|
||||
...
|
||||
alternative spellings help to improve the recognition accuracy of abbreviations and complicated words,
|
||||
which are often recognized as separate words (gpu -> g p u, nvlink -> nv link, tensorrt -> tensor rt, and so on).
|
||||
|
||||
|
||||
# Grid Search for Hyper parameters
|
||||
|
||||
For grid search, you can provide a list of arguments as follows -
|
||||
|
||||
beam_threshold=[4.0,5.0,6.0,....] \
|
||||
context_score=[1.0,1.5,...,4.0,4.5] \
|
||||
ctc_ali_token_weight=[0.1,0.2,...,0.7,0.8] \
|
||||
|
||||
"""
|
||||
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from kaldialign import edit_distance
|
||||
from omegaconf import MISSING, OmegaConf
|
||||
from sklearn.model_selection import ParameterGrid
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.collections.asr.models import EncDecCTCModelBPE, EncDecHybridRNNTCTCModel
|
||||
from nemo.collections.asr.parts import context_biasing
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvalContextBiasingConfig:
|
||||
"""
|
||||
Evaluate CTC and Transducer (RNNT) ASR models in greedy decoding with context biasing.
|
||||
"""
|
||||
|
||||
# # The path of the '.nemo' file of the ASR model or the name of a pretrained model (ngc / huggingface)
|
||||
nemo_model_file: str = MISSING
|
||||
|
||||
# File paths
|
||||
input_manifest: str = MISSING # The manifest file of the evaluation set
|
||||
preds_output_folder: str = MISSING # The folder where the predictions are stored
|
||||
|
||||
# Parameters for inference
|
||||
acoustic_batch_size: int = 128 # The batch size to calculate log probabilities
|
||||
beam_batch_size: int = 128 # The batch size to be used for beam search decoding
|
||||
device: str = "cuda" # The device to load the model onto to calculate log probabilities
|
||||
use_amp: bool = False # Whether to use AMP if available to calculate log probabilities
|
||||
num_workers: int = 1 # Number of workers for DataLoader
|
||||
decoder_type: Optional[str] = None # [ctc, rnnt] decoder type for asr model
|
||||
|
||||
# Context-Biasing params
|
||||
apply_context_biasing: bool = False # True in case of context biasing
|
||||
context_file: str = MISSING # text file with context biasing words and their spellings
|
||||
spelling_separator: str = "_" # separator between word and its spellings in context biasing file
|
||||
beam_threshold: list[float] = field(default_factory=lambda: [5.0]) # beam pruning threshold for ctc-ws decoding
|
||||
context_score: list[float] = field(default_factory=lambda: [3.0]) # per token weight for context biasing words
|
||||
ctc_ali_token_weight: list[float] = field(
|
||||
default_factory=lambda: [0.6]
|
||||
) # weight of CTC tokens to prevent false accept errors
|
||||
print_cb_stats: bool = False # print context biasing stats (mostly for debugging)
|
||||
|
||||
# Auxiliary parameters
|
||||
sort_logits: bool = True # do logits sorting before decoding - it reduces computation on puddings
|
||||
softmax_temperature: float = 1.00
|
||||
preserve_alignments: bool = False
|
||||
|
||||
|
||||
def decoding_step(
|
||||
asr_model: nemo_asr.models.ASRModel,
|
||||
cfg: EvalContextBiasingConfig,
|
||||
encoder_outputs: list[torch.Tensor],
|
||||
ctc_logprobs: list[np.ndarray],
|
||||
target_transcripts: list[str],
|
||||
audio_file_paths: list[str],
|
||||
durations: list[str],
|
||||
preds_output_manifest: str,
|
||||
beam_batch_size: int = 128,
|
||||
progress_bar: bool = True,
|
||||
context_graph: context_biasing.ContextGraphCTC = None,
|
||||
blank_idx: int = 0,
|
||||
hp: Optional[Dict] = None,
|
||||
) -> tuple[float, float]:
|
||||
|
||||
# run CTC-based Word Spotter:
|
||||
if cfg.apply_context_biasing:
|
||||
ws_results = {}
|
||||
for idx, logits in tqdm(
|
||||
enumerate(ctc_logprobs), desc=f"Eval CTC-based Word Spotter...", ncols=120, total=len(ctc_logprobs)
|
||||
):
|
||||
ws_results[audio_file_paths[idx]] = context_biasing.run_word_spotter(
|
||||
logits,
|
||||
context_graph,
|
||||
asr_model,
|
||||
blank_idx=blank_idx,
|
||||
beam_threshold=hp['beam_threshold'],
|
||||
cb_weight=hp['context_score'],
|
||||
ctc_ali_token_weight=hp['ctc_ali_token_weight'],
|
||||
)
|
||||
|
||||
level = logging.getEffectiveLevel()
|
||||
logging.setLevel(logging.CRITICAL)
|
||||
# reset config
|
||||
asr_model.change_decoding_strategy(None)
|
||||
|
||||
# preserve alignment:
|
||||
asr_model.cfg.decoding.preserve_alignments = cfg.preserve_alignments
|
||||
|
||||
# update model's decoding strategy config
|
||||
if isinstance(asr_model, EncDecCTCModelBPE):
|
||||
# in case of ctc
|
||||
asr_model.cfg.decoding.strategy = "greedy"
|
||||
else:
|
||||
# in case of rnnt
|
||||
asr_model.cfg.decoding.strategy = "greedy_batch"
|
||||
# fast greedy batch decoding:
|
||||
asr_model.cfg.decoding.greedy.loop_labels = True
|
||||
|
||||
# update model's decoding strategy
|
||||
asr_model.change_decoding_strategy(asr_model.cfg.decoding)
|
||||
logging.setLevel(level)
|
||||
|
||||
wer_dist_first = cer_dist_first = 0
|
||||
words_count = chars_count = sample_idx = 0
|
||||
|
||||
out_manifest = open(preds_output_manifest, 'w', encoding='utf_8', newline='\n')
|
||||
|
||||
# ctc part for both EncDecCTCModelBPE and EncDecHybridRNNTCTCModel
|
||||
if cfg.decoder_type == "ctc":
|
||||
for batch_idx, probs in enumerate(ctc_logprobs):
|
||||
preds = np.argmax(probs, axis=1)
|
||||
if cfg.apply_context_biasing and ws_results[audio_file_paths[batch_idx]]:
|
||||
# make new text by mearging alignment with ctc-ws predictions:
|
||||
if cfg.print_cb_stats:
|
||||
logging.info("\n" + "********" * 10)
|
||||
logging.info(f"File name: {audio_file_paths[batch_idx]}")
|
||||
pred_text, raw_text = context_biasing.merge_alignment_with_ws_hyps(
|
||||
preds,
|
||||
asr_model,
|
||||
ws_results[audio_file_paths[batch_idx]],
|
||||
decoder_type="ctc",
|
||||
blank_idx=blank_idx,
|
||||
print_stats=cfg.print_cb_stats,
|
||||
)
|
||||
if cfg.print_cb_stats:
|
||||
logging.info(f"raw text: {raw_text}")
|
||||
logging.info(f"hyp text: {pred_text}")
|
||||
logging.info(f"ref text: {target_transcripts[batch_idx]}")
|
||||
else:
|
||||
preds_tensor = torch.tensor(preds, device='cpu').unsqueeze(0)
|
||||
if isinstance(asr_model, EncDecHybridRNNTCTCModel):
|
||||
hyp = asr_model.ctc_decoding.ctc_decoder_predictions_tensor(preds_tensor)[0]
|
||||
else:
|
||||
hyp = asr_model.wer.decoding.ctc_decoder_predictions_tensor(preds_tensor)[0]
|
||||
pred_text = hyp.text
|
||||
pred_split_w = pred_text.split()
|
||||
target_split_w = target_transcripts[batch_idx].split()
|
||||
pred_split_c = list(pred_text)
|
||||
target_split_c = list(target_transcripts[batch_idx])
|
||||
|
||||
wer_dist = edit_distance(target_split_w, pred_split_w)['total']
|
||||
cer_dist = edit_distance(target_split_c, pred_split_c)['total']
|
||||
|
||||
wer_dist_first += wer_dist
|
||||
cer_dist_first += cer_dist
|
||||
words_count += len(target_split_w)
|
||||
chars_count += len(target_split_c)
|
||||
|
||||
if preds_output_manifest:
|
||||
item = {
|
||||
'audio_filepath': audio_file_paths[batch_idx],
|
||||
'duration': durations[batch_idx],
|
||||
'text': target_transcripts[batch_idx],
|
||||
'pred_text': pred_text,
|
||||
'wer': f"{wer_dist/len(target_split_w):.4f}",
|
||||
}
|
||||
print(json.dumps(item), file=out_manifest)
|
||||
out_manifest.close()
|
||||
|
||||
return wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
|
||||
# rnnt part for EncDecHybridRNNTCTCModel
|
||||
else:
|
||||
if progress_bar:
|
||||
description = "Greedy_batch decoding.."
|
||||
it = tqdm(range(int(np.ceil(len(encoder_outputs) / beam_batch_size))), desc=description, ncols=120)
|
||||
else:
|
||||
it = range(int(np.ceil(len(encoder_outputs) / beam_batch_size)))
|
||||
for batch_idx in it:
|
||||
probs_batch = encoder_outputs[batch_idx * beam_batch_size : (batch_idx + 1) * beam_batch_size]
|
||||
probs_lens = torch.tensor([prob.shape[-1] for prob in probs_batch])
|
||||
with torch.no_grad():
|
||||
packed_batch = torch.zeros(len(probs_batch), probs_batch[0].shape[0], max(probs_lens), device='cpu')
|
||||
|
||||
for prob_index in range(len(probs_batch)):
|
||||
packed_batch[prob_index, :, : probs_lens[prob_index]] = torch.tensor(
|
||||
probs_batch[prob_index].unsqueeze(0), device=packed_batch.device, dtype=packed_batch.dtype
|
||||
)
|
||||
best_hyp_batch = asr_model.decoding.rnnt_decoder_predictions_tensor(
|
||||
packed_batch,
|
||||
probs_lens,
|
||||
return_hypotheses=True,
|
||||
)
|
||||
beams_batch = [[x] for x in best_hyp_batch]
|
||||
|
||||
for beams_idx, beams in enumerate(beams_batch):
|
||||
target = target_transcripts[sample_idx + beams_idx]
|
||||
target_split_w = target.split()
|
||||
target_split_c = list(target)
|
||||
words_count += len(target_split_w)
|
||||
chars_count += len(target_split_c)
|
||||
for candidate_idx, candidate in enumerate(beams):
|
||||
if cfg.apply_context_biasing and ws_results[audio_file_paths[sample_idx + beams_idx]]:
|
||||
# make new text by mearging alignment with ctc-ws predictions:
|
||||
if cfg.print_cb_stats:
|
||||
logging.info("\n" + "********" * 10)
|
||||
logging.info(f"File name: {audio_file_paths[batch_idx]}")
|
||||
pred_text, raw_text = context_biasing.merge_alignment_with_ws_hyps(
|
||||
candidate,
|
||||
asr_model,
|
||||
ws_results[audio_file_paths[sample_idx + beams_idx]],
|
||||
decoder_type="rnnt",
|
||||
blank_idx=blank_idx,
|
||||
print_stats=cfg.print_cb_stats,
|
||||
)
|
||||
if cfg.print_cb_stats:
|
||||
logging.info(f"raw text: {raw_text}")
|
||||
logging.info(f"hyp text: {pred_text}")
|
||||
logging.info(f"ref text: {target_transcripts[sample_idx + beams_idx]}")
|
||||
else:
|
||||
pred_text = candidate.text
|
||||
|
||||
pred_split_w = pred_text.split()
|
||||
wer_dist = edit_distance(target_split_w, pred_split_w)['total']
|
||||
pred_split_c = list(pred_text)
|
||||
cer_dist = edit_distance(target_split_c, pred_split_c)['total']
|
||||
|
||||
if candidate_idx == 0:
|
||||
# first candidate
|
||||
wer_dist_tosave = wer_dist
|
||||
wer_dist_first += wer_dist
|
||||
cer_dist_first += cer_dist
|
||||
|
||||
# write manifest with prediction results
|
||||
alignment = []
|
||||
|
||||
if preds_output_manifest:
|
||||
item = {
|
||||
'audio_filepath': audio_file_paths[sample_idx + beams_idx],
|
||||
'duration': durations[sample_idx + beams_idx],
|
||||
'text': target_transcripts[sample_idx + beams_idx],
|
||||
'pred_text': pred_text,
|
||||
'wer': f"{wer_dist_tosave/len(target_split_w):.3f}",
|
||||
'alignment': f"{alignment}",
|
||||
}
|
||||
print(json.dumps(item), file=out_manifest)
|
||||
|
||||
sample_idx += len(probs_batch)
|
||||
out_manifest.close()
|
||||
|
||||
return wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
|
||||
|
||||
@hydra_runner(config_path=None, config_name='EvalContextBiasingConfig', schema=EvalContextBiasingConfig)
|
||||
def main(cfg: EvalContextBiasingConfig):
|
||||
if is_dataclass(cfg):
|
||||
cfg = OmegaConf.structured(cfg)
|
||||
|
||||
assert os.path.isfile(cfg.input_manifest), f"input_manifest {cfg.input_manifest} does not exist"
|
||||
assert cfg.context_file, "context_file must be provided for f-score computation"
|
||||
assert os.path.isfile(cfg.context_file), f"context_file {cfg.context_file} does not exist"
|
||||
assert cfg.decoder_type in ["ctc", "rnnt"], "decoder_type must be ctc or rnnt"
|
||||
assert cfg.preds_output_folder, "preds_output_folder must be provided"
|
||||
assert os.path.isdir(cfg.preds_output_folder), f"preds_output_folder {cfg.preds_output_folder} does not exist"
|
||||
|
||||
# load nemo asr model
|
||||
if cfg.nemo_model_file.endswith('.nemo'):
|
||||
asr_model = nemo_asr.models.ASRModel.restore_from(cfg.nemo_model_file, map_location=torch.device(cfg.device))
|
||||
else:
|
||||
logging.warning(
|
||||
"nemo_model_file does not end with .nemo, therefore trying to load a pretrained model with this name."
|
||||
)
|
||||
asr_model = nemo_asr.models.ASRModel.from_pretrained(
|
||||
cfg.nemo_model_file, map_location=torch.device(cfg.device)
|
||||
)
|
||||
if not isinstance(asr_model, (EncDecCTCModelBPE, EncDecHybridRNNTCTCModel)):
|
||||
raise ValueError("ASR model must be CTC BPE or Hybrid Transducer-CTC")
|
||||
|
||||
# load nemo manifest
|
||||
target_transcripts = []
|
||||
durations = []
|
||||
manifest_dir = Path(cfg.input_manifest).parent
|
||||
with open(cfg.input_manifest, 'r', encoding='utf_8') as manifest_file:
|
||||
audio_file_paths = []
|
||||
for line in tqdm(manifest_file, desc=f"Reading Manifest {cfg.input_manifest} ...", ncols=120):
|
||||
data = json.loads(line)
|
||||
audio_file = Path(data['audio_filepath'])
|
||||
if not audio_file.is_file() and not audio_file.is_absolute():
|
||||
audio_file = manifest_dir / audio_file
|
||||
target_transcripts.append(data['text'])
|
||||
durations.append(data['duration'])
|
||||
audio_file_paths.append(str(audio_file.absolute()))
|
||||
|
||||
# manual calculation of encoder_embeddings
|
||||
with torch.amp.autocast(asr_model.device.type, enabled=cfg.use_amp):
|
||||
with torch.no_grad():
|
||||
asr_model.eval()
|
||||
asr_model.encoder.freeze()
|
||||
device = next(asr_model.parameters()).device
|
||||
encoder_outputs = []
|
||||
ctc_logprobs = []
|
||||
if isinstance(asr_model, EncDecCTCModelBPE):
|
||||
# in case of EncDecCTCModelBPE
|
||||
hyp_results = asr_model.transcribe(
|
||||
audio_file_paths, batch_size=cfg.acoustic_batch_size, return_hypotheses=True
|
||||
)
|
||||
ctc_logprobs = [hyp.alignments.cpu().numpy() for hyp in hyp_results]
|
||||
blank_idx = asr_model.decoding.blank_id
|
||||
else:
|
||||
# in case of EncDecHybridRNNTCTCModel
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, 'manifest.json'), 'w', encoding='utf-8') as fp:
|
||||
for audio_file in audio_file_paths:
|
||||
entry = {'audio_filepath': audio_file, 'duration': 100000, 'text': ''}
|
||||
fp.write(json.dumps(entry) + '\n')
|
||||
config = {
|
||||
'paths2audio_files': audio_file_paths,
|
||||
'batch_size': cfg.acoustic_batch_size,
|
||||
'temp_dir': tmpdir,
|
||||
'num_workers': cfg.num_workers,
|
||||
'channel_selector': None,
|
||||
'augmentor': None,
|
||||
}
|
||||
temporary_datalayer = asr_model._setup_transcribe_dataloader(config)
|
||||
|
||||
for test_batch in tqdm(
|
||||
temporary_datalayer, desc="Getting encoder and CTC decoder outputs...", disable=False
|
||||
):
|
||||
encoded, encoded_len = asr_model.forward(
|
||||
input_signal=test_batch[0].to(device), input_signal_length=test_batch[1].to(device)
|
||||
)
|
||||
ctc_dec_outputs = asr_model.ctc_decoder(encoder_output=encoded).cpu()
|
||||
# dump encoder embeddings per file
|
||||
for idx in range(encoded.shape[0]):
|
||||
encoded_no_pad = encoded[idx, :, : encoded_len[idx]]
|
||||
ctc_dec_outputs_no_pad = ctc_dec_outputs[idx, : encoded_len[idx]]
|
||||
encoder_outputs.append(encoded_no_pad)
|
||||
ctc_logprobs.append(ctc_dec_outputs_no_pad.cpu().numpy())
|
||||
blank_idx = asr_model.decoder.blank_idx
|
||||
|
||||
# load context biasing words
|
||||
context_transcripts = []
|
||||
for line in open(cfg.context_file).readlines():
|
||||
item = line.strip().lower().split(cfg.spelling_separator)
|
||||
word = item[0]
|
||||
word_tokenization = [asr_model.tokenizer.text_to_ids(x) for x in item[1:]]
|
||||
context_transcripts.append([word, word_tokenization])
|
||||
context_words = [item[0] for item in context_transcripts]
|
||||
# build context graph:
|
||||
if cfg.apply_context_biasing:
|
||||
context_graph = context_biasing.ContextGraphCTC(blank_id=blank_idx)
|
||||
context_graph.add_to_graph(context_transcripts)
|
||||
else:
|
||||
context_graph = None
|
||||
|
||||
# sort encoder_outputs according to length:
|
||||
if cfg.decoder_type == "rnnt" and cfg.sort_logits:
|
||||
encoder_outputs_with_indeces = sorted(enumerate(encoder_outputs), key=lambda x: x[1].size()[1], reverse=True)
|
||||
encoder_outputs_sorted = []
|
||||
target_transcripts_sorted = []
|
||||
audio_file_paths_sorted = []
|
||||
durations_sorted = []
|
||||
ctc_logprobs_sorted = []
|
||||
for pair in encoder_outputs_with_indeces:
|
||||
encoder_outputs_sorted.append(pair[1])
|
||||
target_transcripts_sorted.append(target_transcripts[pair[0]])
|
||||
audio_file_paths_sorted.append(audio_file_paths[pair[0]])
|
||||
durations_sorted.append(durations[pair[0]])
|
||||
ctc_logprobs_sorted.append(ctc_logprobs[pair[0]])
|
||||
encoder_outputs = encoder_outputs_sorted
|
||||
target_transcripts = target_transcripts_sorted
|
||||
audio_file_paths = audio_file_paths_sorted
|
||||
durations = durations_sorted
|
||||
ctc_logprobs = ctc_logprobs_sorted
|
||||
|
||||
# setup search parameters grid
|
||||
params = {
|
||||
'beam_threshold': cfg.beam_threshold,
|
||||
'context_score': cfg.context_score,
|
||||
'ctc_ali_token_weight': cfg.ctc_ali_token_weight,
|
||||
}
|
||||
hp_grid = ParameterGrid(params)
|
||||
hp_grid = list(hp_grid)
|
||||
|
||||
logging.info(f"=========================Starting the decoding========================")
|
||||
logging.info(f"Grid search size: {len(hp_grid)}")
|
||||
logging.info(f"It may take some time...")
|
||||
logging.info(f"======================================================================")
|
||||
|
||||
asr_model = asr_model.to('cpu')
|
||||
best_wer = 1e6
|
||||
|
||||
# run decoding step for each hyper parameter set
|
||||
for hp in hp_grid:
|
||||
results_file = f"preds_out_manifest_bthr-{hp['beam_threshold']}_cs-{hp['context_score']}ctcw-{hp['ctc_ali_token_weight']}.json"
|
||||
preds_output_manifest = os.path.join(cfg.preds_output_folder, results_file)
|
||||
candidate_wer, candidate_cer = decoding_step(
|
||||
asr_model,
|
||||
cfg,
|
||||
encoder_outputs=encoder_outputs,
|
||||
target_transcripts=target_transcripts,
|
||||
audio_file_paths=audio_file_paths,
|
||||
durations=durations,
|
||||
beam_batch_size=cfg.beam_batch_size,
|
||||
progress_bar=True,
|
||||
preds_output_manifest=preds_output_manifest,
|
||||
context_graph=context_graph,
|
||||
ctc_logprobs=ctc_logprobs,
|
||||
blank_idx=blank_idx,
|
||||
hp=hp,
|
||||
)
|
||||
|
||||
# compute fscore
|
||||
fscore_stats = context_biasing.compute_fscore(preds_output_manifest, context_words)
|
||||
|
||||
# find the best wer value
|
||||
if candidate_wer < best_wer:
|
||||
best_beam_threshold = hp["beam_threshold"]
|
||||
best_context_score = hp["context_score"]
|
||||
best_ctc_ali_token_weight = hp["ctc_ali_token_weight"]
|
||||
best_wer = candidate_wer
|
||||
best_fscore_stats = fscore_stats
|
||||
|
||||
logging.info(f"======================================================================")
|
||||
logging.info(f"Greedy WER/CER = {candidate_wer:.2%}/{candidate_cer:.2%}")
|
||||
logging.info(f"Precision/Recall/Fscore = {fscore_stats[0]:.4f}/{fscore_stats[1]:.4f}/{fscore_stats[2]:.4f}")
|
||||
logging.info(
|
||||
f"Params: b_thr = {hp['beam_threshold']}, cs = {hp['context_score']}, ctc_ali_weight = {hp['ctc_ali_token_weight']}"
|
||||
)
|
||||
logging.info(f"======================================================================")
|
||||
|
||||
if len(hp_grid) > 1:
|
||||
logging.info(f"=========================Best Results=================================")
|
||||
logging.info(f"Best WER = {best_wer:.2%}")
|
||||
logging.info(
|
||||
f"Best Precision/Recall/Fscore = {best_fscore_stats[0]:.4f}/{best_fscore_stats[1]:.4f}/{best_fscore_stats[2]:.4f}"
|
||||
)
|
||||
logging.info(
|
||||
f"Best beam_threshold = {best_beam_threshold}, context_score = {best_context_score}, ctc_ali_token_weight = {best_ctc_ali_token_weight}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,222 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
A script to add EOB labels to a manifest file.
|
||||
|
||||
Example usage:
|
||||
|
||||
```bash
|
||||
python add_eob_labels.py /path/to/manifest/dir
|
||||
```
|
||||
where output will be saved in the same directory with `-eob` suffix added to the filename.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from string import punctuation
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
parser = argparse.ArgumentParser(description="Add `is_backchannel` labels to manifest files.")
|
||||
parser.add_argument(
|
||||
"input_manifest",
|
||||
type=str,
|
||||
help="Path to the input manifest file to be cleaned.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to the output manifest file after cleaning.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--pattern",
|
||||
type=str,
|
||||
default="*.json",
|
||||
help="Pattern to match files in the input directory.",
|
||||
)
|
||||
|
||||
|
||||
def read_manifest(manifest_path):
|
||||
manifest = []
|
||||
with open(manifest_path, 'r') as f:
|
||||
for line in f.readlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
manifest.append(json.loads(line))
|
||||
return manifest
|
||||
|
||||
|
||||
def write_manifest(manifest_path, manifest):
|
||||
with open(manifest_path, 'w') as f:
|
||||
for item in manifest:
|
||||
f.write(json.dumps(item) + '\n')
|
||||
|
||||
|
||||
def clean_text(text):
|
||||
text = text.translate(str.maketrans('', '', punctuation)).lower().strip()
|
||||
valid_chars = "abcdefghijklmnopqrstuvwxyz'"
|
||||
text = ''.join([c for c in text if c in valid_chars or c.isspace() or c == "'"])
|
||||
return " ".join(text.split()).strip()
|
||||
|
||||
|
||||
backchannel_phrases = [
|
||||
'absolutely',
|
||||
'ah',
|
||||
'all right',
|
||||
'alright',
|
||||
'but yeah',
|
||||
'definitely',
|
||||
'exactly',
|
||||
'go ahead',
|
||||
'good',
|
||||
'great',
|
||||
'great thanks',
|
||||
'ha ha',
|
||||
'hi',
|
||||
'i know',
|
||||
'i know right',
|
||||
'i see',
|
||||
'indeed',
|
||||
'interesting',
|
||||
'mhmm',
|
||||
'mhmm mhmm',
|
||||
'mhmm right',
|
||||
'mhmm yeah',
|
||||
'mhmm yes',
|
||||
'nice',
|
||||
'of course',
|
||||
'oh',
|
||||
'oh dear',
|
||||
'oh man',
|
||||
'oh okay',
|
||||
'oh wow',
|
||||
'oh yes',
|
||||
'ok',
|
||||
'ok thanks',
|
||||
'okay',
|
||||
'okay okay',
|
||||
'okay thanks',
|
||||
'perfect',
|
||||
'really',
|
||||
'right',
|
||||
'right exactly',
|
||||
'right right',
|
||||
'right yeah',
|
||||
'so yeah',
|
||||
'sounds good',
|
||||
'sure',
|
||||
'thank you',
|
||||
'thanks',
|
||||
"that's awesome",
|
||||
'thats right',
|
||||
'thats true',
|
||||
'true',
|
||||
'uh-huh',
|
||||
'uh-huh yeah',
|
||||
'uhhuh',
|
||||
'um-humm',
|
||||
'well',
|
||||
'what',
|
||||
'wow',
|
||||
'yeah',
|
||||
'yeah i know',
|
||||
'yeah i see',
|
||||
'yeah mhmm',
|
||||
'yeah okay',
|
||||
'yeah right',
|
||||
'yeah uh-huh',
|
||||
'yeah yeah',
|
||||
'yep',
|
||||
'yes',
|
||||
'yes please',
|
||||
'yes yes',
|
||||
'you know',
|
||||
"you're right",
|
||||
]
|
||||
|
||||
backchannel_phrases_nopc = [clean_text(phrase) for phrase in backchannel_phrases]
|
||||
|
||||
|
||||
def check_if_backchannel(text):
|
||||
"""
|
||||
Check if the text is a backchannel phrase.
|
||||
"""
|
||||
# Remove punctuation and convert to lowercase
|
||||
text = clean_text(text)
|
||||
# Check if the text is in the list of backchannel phrases
|
||||
return text in backchannel_phrases_nopc
|
||||
|
||||
|
||||
def add_eob_labels(manifest_path):
|
||||
"""
|
||||
Add EOB labels to a manifest file.
|
||||
Args:
|
||||
manifest_path: Path to the manifest file.
|
||||
|
||||
Returns:
|
||||
manifest: List of dictionaries with the EOB label added.
|
||||
num_eob: Number of EOB labels added.
|
||||
"""
|
||||
num_eob = 0
|
||||
manifest = read_manifest(manifest_path)
|
||||
for i, item in enumerate(manifest):
|
||||
text = item['text']
|
||||
# Check if the text is a backchannel phrase
|
||||
is_backchannel = check_if_backchannel(text)
|
||||
# Add the EOB label to the text
|
||||
if is_backchannel:
|
||||
item['is_backchannel'] = True
|
||||
num_eob += 1
|
||||
else:
|
||||
item['is_backchannel'] = False
|
||||
manifest[i] = item
|
||||
return manifest, num_eob
|
||||
|
||||
|
||||
def main():
|
||||
args = parser.parse_args()
|
||||
input_manifest = Path(args.input_manifest)
|
||||
|
||||
if input_manifest.is_dir():
|
||||
manifest_list = list(input_manifest.glob(args.pattern))
|
||||
if not manifest_list:
|
||||
raise ValueError(f"No files found in {input_manifest} matching pattern `{args.pattern}`")
|
||||
else:
|
||||
manifest_list = [input_manifest]
|
||||
|
||||
if args.output is None:
|
||||
output_dir = input_manifest if input_manifest.is_dir() else input_manifest.parent
|
||||
else:
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
total_num_eob = 0
|
||||
print(f"Processing {len(manifest_list)} manifest files...")
|
||||
for manifest_path in tqdm(manifest_list, total=len(manifest_list)):
|
||||
output_file = output_dir / f"{manifest_path.stem}-eob.json"
|
||||
new_manifest, num_eob = add_eob_labels(manifest_path)
|
||||
total_num_eob += num_eob
|
||||
write_manifest(output_file, new_manifest)
|
||||
print(f"Processed {manifest_path} and saved to {output_file}. Number of EOB labels added: {num_eob}")
|
||||
|
||||
print(f"Total number of EOB labels added: {total_num_eob}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,670 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
A rule-based text cleaning script for preparing text for ASR-EOU model training.
|
||||
|
||||
Example usage:
|
||||
|
||||
```bash
|
||||
python clean_manifest.py \
|
||||
/path/to/manifest/dir \
|
||||
-o /path/to/output/dir
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from string import punctuation
|
||||
|
||||
from num2words import num2words
|
||||
from whisper_normalizer.english import EnglishTextNormalizer
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest, write_manifest
|
||||
|
||||
punctuations = punctuation.replace("'", "")
|
||||
|
||||
text_normalizer = EnglishTextNormalizer()
|
||||
|
||||
parser = argparse.ArgumentParser(description="Clean manifest file")
|
||||
parser.add_argument(
|
||||
"input_manifest",
|
||||
type=str,
|
||||
help="Path to the input manifest file to be cleaned.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to the output manifest file after cleaning.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-lower",
|
||||
"--lowercase",
|
||||
type=bool,
|
||||
default=False,
|
||||
help="Whether to convert the text to lowercase.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-drop",
|
||||
"--remove_punc",
|
||||
type=bool,
|
||||
default=False,
|
||||
help="Whether to remove punctuation from the text.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--normalize",
|
||||
type=bool,
|
||||
default=False,
|
||||
help="Whether to normalize the text using Whisper text normalizer.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-n2w",
|
||||
"--replace_numbers",
|
||||
type=bool,
|
||||
default=True,
|
||||
help="Whether to replace numbers with words.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--pattern",
|
||||
type=str,
|
||||
default="**/*.json",
|
||||
help="Pattern to match files in the input directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--text_field",
|
||||
type=str,
|
||||
default="text",
|
||||
help="Field in the manifest to clean. Default is 'text'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--auto_pc",
|
||||
action="store_true",
|
||||
help="If set, will add auto capitalization and punctuation at the end of the text.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--format",
|
||||
default="asr",
|
||||
choices=["asr", "conv"],
|
||||
help="Format of the manifest. Default is 'asr'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep_name",
|
||||
action="store_true",
|
||||
help="If set, will keep the original name of the manifest file.",
|
||||
)
|
||||
|
||||
# Spoken representations
|
||||
|
||||
MONTHS = [
|
||||
"",
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
]
|
||||
|
||||
ORDINALS = {
|
||||
1: "first",
|
||||
2: "second",
|
||||
3: "third",
|
||||
4: "fourth",
|
||||
5: "fifth",
|
||||
6: "sixth",
|
||||
7: "seventh",
|
||||
8: "eighth",
|
||||
9: "ninth",
|
||||
10: "tenth",
|
||||
11: "eleventh",
|
||||
12: "twelfth",
|
||||
13: "thirteenth",
|
||||
14: "fourteenth",
|
||||
15: "fifteenth",
|
||||
16: "sixteenth",
|
||||
17: "seventeenth",
|
||||
18: "eighteenth",
|
||||
19: "nineteenth",
|
||||
20: "twentieth",
|
||||
21: "twenty first",
|
||||
22: "twenty second",
|
||||
23: "twenty third",
|
||||
24: "twenty fourth",
|
||||
25: "twenty fifth",
|
||||
26: "twenty sixth",
|
||||
27: "twenty seventh",
|
||||
28: "twenty eighth",
|
||||
29: "twenty ninth",
|
||||
30: "thirtieth",
|
||||
31: "thirty first",
|
||||
}
|
||||
|
||||
|
||||
def speak_year(year: int) -> str:
|
||||
if 2000 <= year <= 2099:
|
||||
return f"twenty {speak_number(year % 100)}"
|
||||
elif 1900 <= year <= 1999:
|
||||
return f"nineteen {speak_number(year % 100)}"
|
||||
else:
|
||||
return str(year)
|
||||
|
||||
|
||||
def speak_number(n: int) -> str:
|
||||
num_words = {
|
||||
0: "zero",
|
||||
1: "one",
|
||||
2: "two",
|
||||
3: "three",
|
||||
4: "four",
|
||||
5: "five",
|
||||
6: "six",
|
||||
7: "seven",
|
||||
8: "eight",
|
||||
9: "nine",
|
||||
10: "ten",
|
||||
11: "eleven",
|
||||
12: "twelve",
|
||||
13: "thirteen",
|
||||
14: "fourteen",
|
||||
15: "fifteen",
|
||||
16: "sixteen",
|
||||
17: "seventeen",
|
||||
18: "eighteen",
|
||||
19: "nineteen",
|
||||
20: "twenty",
|
||||
30: "thirty",
|
||||
40: "forty",
|
||||
50: "fifty",
|
||||
60: "sixty",
|
||||
70: "seventy",
|
||||
80: "eighty",
|
||||
90: "ninety",
|
||||
}
|
||||
if n <= 20:
|
||||
return num_words[n]
|
||||
elif n < 100:
|
||||
tens, ones = divmod(n, 10)
|
||||
return f"{num_words[tens * 10]} {num_words[ones]}" if ones else num_words[tens * 10]
|
||||
else:
|
||||
return str(n)
|
||||
|
||||
|
||||
def _parse_numeric_date(date_str: str, *, dayfirst: bool):
|
||||
separator = "/" if "/" in date_str else "-"
|
||||
parts = date_str.split(separator)
|
||||
|
||||
if len(parts) == 3 and len(parts[0]) == 4:
|
||||
year, month, day = [int(part) for part in parts]
|
||||
elif len(parts) == 3:
|
||||
first, second, year = [int(part) for part in parts]
|
||||
day, month = (first, second) if dayfirst else (second, first)
|
||||
elif len(parts) == 2:
|
||||
first, second = [int(part) for part in parts]
|
||||
year = datetime.now().year
|
||||
day, month = (first, second) if dayfirst else (second, first)
|
||||
else:
|
||||
raise ValueError(date_str)
|
||||
|
||||
if year < 100:
|
||||
year += 2000 if year < 69 else 1900
|
||||
|
||||
return datetime(year, month, day)
|
||||
|
||||
|
||||
def parse_with_auto_dayfirst(date_str: str):
|
||||
try:
|
||||
parsed_us = _parse_numeric_date(date_str, dayfirst=False)
|
||||
except Exception:
|
||||
parsed_us = None
|
||||
|
||||
try:
|
||||
parsed_eu = _parse_numeric_date(date_str, dayfirst=True)
|
||||
except Exception:
|
||||
parsed_eu = None
|
||||
|
||||
if parsed_us is None:
|
||||
return parsed_eu
|
||||
if parsed_eu is None:
|
||||
return parsed_us
|
||||
|
||||
first = int(date_str.split("/" if "/" in date_str else "-")[0])
|
||||
if first > 12 and len(str(first)) != 4:
|
||||
return parsed_eu
|
||||
|
||||
# Default fallback assumes US style for ambiguous numeric dates.
|
||||
return parsed_us
|
||||
|
||||
|
||||
def date_to_spoken_string(date_str: str) -> str:
|
||||
parsed = parse_with_auto_dayfirst(date_str)
|
||||
if not parsed:
|
||||
return None
|
||||
|
||||
month = MONTHS[parsed.month]
|
||||
day = ORDINALS[parsed.day]
|
||||
spoken = f"{month} {day} {speak_year(parsed.year)}"
|
||||
|
||||
return spoken
|
||||
|
||||
|
||||
def replace_dates_in_text(text: str) -> str:
|
||||
# Regex pattern to match common date formats like:
|
||||
# 5/22, 05/22/2025, 22/05/2025, 2025-05-22
|
||||
date_pattern = r'\b(?:\d{1,4}[-/])?\d{1,2}[-/]\d{1,4}\b'
|
||||
|
||||
def replace_match(match):
|
||||
date_str = match.group(0)
|
||||
spoken = date_to_spoken_string(date_str)
|
||||
return spoken if spoken else date_str
|
||||
|
||||
return re.sub(date_pattern, replace_match, text)
|
||||
|
||||
|
||||
def convert_to_spoken(text: str) -> str:
|
||||
|
||||
text = replace_dates_in_text(text) # Convert dates to spoken form
|
||||
|
||||
# Mapping of metric units to spoken forms
|
||||
unit_map = {
|
||||
"kg": "kilograms",
|
||||
"g": "grams",
|
||||
"mg": "milligrams",
|
||||
"l": "liters",
|
||||
"ml": "milliliters",
|
||||
"cm": "centimeters",
|
||||
"mm": "millimeters",
|
||||
"m": "meters",
|
||||
"km": "kilometers",
|
||||
"°c": "degrees celsius",
|
||||
"°f": "degrees fahrenheit",
|
||||
"oz": "ounces",
|
||||
"lb": "pounds",
|
||||
"lbs": "pounds",
|
||||
}
|
||||
|
||||
# Replace metric units like "12kg" or "5 ml"
|
||||
def replace_metric(match):
|
||||
number = match.group(1)
|
||||
unit = match.group(2).lower()
|
||||
spoken_unit = unit_map.get(unit, unit)
|
||||
return f"{number} {spoken_unit}"
|
||||
|
||||
# Replace time like "5am" or "6PM"
|
||||
def replace_ampm(match):
|
||||
hour = match.group(1)
|
||||
meridiem = match.group(2).lower()
|
||||
return f"{hour} {'a m' if meridiem == 'am' else 'p m'}"
|
||||
|
||||
# Replace time like "1:30pm"
|
||||
def replace_colon_time(match):
|
||||
hour = match.group(1)
|
||||
minute = match.group(2)
|
||||
meridiem = match.group(3).lower()
|
||||
return f"{hour} {minute} {'a m' if meridiem == 'am' else 'p m'}"
|
||||
|
||||
# Convert feet and inches like 5'11" to "5 feet 11 inches"
|
||||
def replace_feet_inches(match):
|
||||
feet = match.group(1)
|
||||
inches = match.group(2)
|
||||
return f"{feet} feet {inches} inches"
|
||||
|
||||
# Convert just feet (e.g., 6') to "6 feet"
|
||||
def replace_feet_only(match):
|
||||
feet = match.group(1)
|
||||
return f"{feet} feet"
|
||||
|
||||
# Convert just inches (e.g., 10") to "10 inches"
|
||||
def replace_inches_only(match):
|
||||
inches = match.group(1)
|
||||
return f"{inches} inches"
|
||||
|
||||
# Apply replacements
|
||||
# First: time with colon (e.g., 1:30pm)
|
||||
text = re.sub(r'\b(\d{1,2}):(\d{2})(am|pm)\b', replace_colon_time, text, flags=re.IGNORECASE)
|
||||
|
||||
# Then: basic am/pm (e.g., 5am)
|
||||
text = re.sub(r'\b(\d{1,2})(am|pm)\b', replace_ampm, text, flags=re.IGNORECASE)
|
||||
|
||||
# Then: replace 1st, 2nd, 3rd, etc
|
||||
text = text.replace("1st", "first")
|
||||
text = text.replace("2nd", "second")
|
||||
text = text.replace("3rd", "third")
|
||||
text = text.replace("@", " at ")
|
||||
|
||||
# Finally: metric units
|
||||
text = re.sub(
|
||||
r'\b(\d+(?:\.\d+)?)\s?(kg|g|mg|l|ml|cm|mm|m|km|°c|°f|oz|lbs?|LB|LBS?)\b',
|
||||
replace_metric,
|
||||
text,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
text = re.sub(r'\b(\d+)\'(\d+)"', replace_feet_inches, text) # e.g., 5'11"
|
||||
text = re.sub(r'\b(\d+)\'', replace_feet_only, text) # e.g., 6'
|
||||
text = re.sub(r'(\d+)"', replace_inches_only, text) # e.g., 10"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def replace_numbers_with_words(text):
|
||||
def convert_number(match):
|
||||
num_str = match.group()
|
||||
original = num_str
|
||||
|
||||
# Remove dollar sign
|
||||
is_dollar = False
|
||||
if num_str.startswith('$'):
|
||||
is_dollar = True
|
||||
num_str = num_str[1:]
|
||||
elif num_str.endswith('$'):
|
||||
is_dollar = True
|
||||
num_str = num_str[:-1]
|
||||
|
||||
# Remove commas
|
||||
num_str = num_str.replace(',', '')
|
||||
|
||||
try:
|
||||
if '.' in num_str:
|
||||
# Convert decimal number
|
||||
integer_part, decimal_part = num_str.split('.')
|
||||
words = num2words(int(integer_part)) + ' point ' + ' '.join(num2words(int(d)) for d in decimal_part)
|
||||
else:
|
||||
words = num2words(int(num_str))
|
||||
if is_dollar:
|
||||
words += ' dollars'
|
||||
return words + " "
|
||||
except Exception:
|
||||
return original # Return original if conversion fails
|
||||
|
||||
# Pattern matches: $3,000 or 3,000.45 or 1234
|
||||
pattern = re.compile(r'\$?\d{1,3}(?:,\d{3})*(?:\.\d+)?|\$?\d+(?:\.\d+)?')
|
||||
result = pattern.sub(convert_number, text)
|
||||
result = result.replace("$", " dollars ") # Handle dollar sign separately
|
||||
|
||||
def merge_th(text: str) -> str:
|
||||
# merge th with the preceding digit
|
||||
candidates = ["four th ", "five th ", "six th ", "seven th ", "eight th ", "nine th "]
|
||||
for key in candidates:
|
||||
if key in text:
|
||||
if "five" in key:
|
||||
target = "fifth "
|
||||
else:
|
||||
target = f"{key.split(' ')[0]}th "
|
||||
text = text.replace(key, target)
|
||||
elif text.endswith(key.strip()):
|
||||
if "five" in key:
|
||||
target = "fifth"
|
||||
else:
|
||||
target = f"{key.split(' ')[0]}th"
|
||||
text = text.replace(key.strip(), target)
|
||||
return text
|
||||
|
||||
result = merge_th(result)
|
||||
result = " ".join(result.split()) # Remove extra spaces
|
||||
return result
|
||||
|
||||
|
||||
def unicode_to_ascii(text: str) -> str:
|
||||
"""
|
||||
Converts text with accented or special Latin characters (e.g., ó, ñ, ū, ō)
|
||||
into their closest ASCII equivalents.
|
||||
"""
|
||||
# Normalize the string to NFKD to separate base characters from diacritics
|
||||
normalized = unicodedata.normalize('NFKD', text)
|
||||
|
||||
# Encode to ASCII bytes, ignoring characters that can't be converted
|
||||
ascii_bytes = normalized.encode('ascii', 'ignore')
|
||||
|
||||
# Decode back to string
|
||||
ascii_text = ascii_bytes.decode('ascii')
|
||||
|
||||
return ascii_text
|
||||
|
||||
|
||||
def drop_punctuations(text: str) -> str:
|
||||
"""
|
||||
Clean the text by removing invalid characters and converting to lowercase.
|
||||
|
||||
:param text: Input text.
|
||||
:return: Cleaned text.
|
||||
"""
|
||||
valid_chars = "abcdefghijklmnopqrstuvwxyz'"
|
||||
text = text.lower()
|
||||
text = unicode_to_ascii(text)
|
||||
text = text.replace(":", " ")
|
||||
text = text.replace("-", " ")
|
||||
text = ''.join([c for c in text if c in valid_chars or c.isspace()])
|
||||
text = ' '.join(text.split()).strip()
|
||||
return text
|
||||
|
||||
|
||||
def clean_label(_str: str) -> str:
|
||||
"""
|
||||
Remove unauthorized characters in a string, lower it and remove unneeded spaces
|
||||
"""
|
||||
# replace_with_space = [char for char in '/?*\",.:=?_{|}~¨«·»¡¿„…‧‹›≪≫!:;ː→']
|
||||
replace_with_blank = [char for char in '`¨´‘’“”`ʻ‘’“"‘”']
|
||||
replace_with_apos = [char for char in '‘’ʻ‘’‘'] + ["\u2019"]
|
||||
_str = _str.strip()
|
||||
for i in replace_with_blank:
|
||||
_str = _str.replace(i, "")
|
||||
for i in replace_with_apos:
|
||||
_str = _str.replace(i, "'")
|
||||
|
||||
text = _str
|
||||
text = text.replace("\u2103", "celsius")
|
||||
text = text.replace("\u2109", "fahrenheit")
|
||||
text = text.replace("\u00b0", "degrees")
|
||||
text = text.replace("\u2019", "'")
|
||||
text = text.replace("\\", ".")
|
||||
text = text.replace("\n", " ")
|
||||
text = text.replace("\r", " ")
|
||||
text = text.replace("\t", " ")
|
||||
|
||||
ret = " ".join(text.split())
|
||||
return ret
|
||||
|
||||
|
||||
def ends_with_punctuation(s: str) -> bool:
|
||||
# Strip trailing whitespace
|
||||
s = s.rstrip()
|
||||
|
||||
# consider this set to be punctuation that's acceptable to end a sentence with
|
||||
puncturation_chars = [",", ".", ":", ";", "?", "!", "-", "—", "–", "…"]
|
||||
|
||||
# If string is empty after stripping, return False
|
||||
if not s:
|
||||
return False
|
||||
|
||||
# Get the last character
|
||||
last_char = s[-1]
|
||||
|
||||
# Return True if the last character is punctuation, otherwise False
|
||||
return last_char in puncturation_chars
|
||||
|
||||
|
||||
def add_period_if_needed(text: str) -> str:
|
||||
"""
|
||||
Add a period at the end of the text if it does not already end with one.
|
||||
"""
|
||||
if not ends_with_punctuation(text):
|
||||
text += "."
|
||||
return text.strip()
|
||||
|
||||
|
||||
def capitalize_self_i(text):
|
||||
# Replace standalone lowercase "i" with "I"
|
||||
# Handles "i", "i.", "i?", "i'll", "i'm", etc.
|
||||
return re.sub(r'\b(i)(?=[\s.,!?;:\'\"-]|$)', r'I', text)
|
||||
|
||||
|
||||
def add_space_after_punctuation(text):
|
||||
# Add a space after punctuation if it's not already followed by one or by the end of the string
|
||||
return re.sub(r'([,\.?;:])(?=\S)', r'\1 ', text)
|
||||
|
||||
|
||||
def add_auto_capitalization(text):
|
||||
if text.lower() != text:
|
||||
# If the text is not all lowercase, we assume it has some capitalization
|
||||
return text
|
||||
|
||||
# Remove space before punctuation (.,!?;:)
|
||||
text = re.sub(r'\s+([.,!?;:])', r'\1', text)
|
||||
|
||||
# Capitalize the first letter of each sentence
|
||||
def capitalize_sentences(match):
|
||||
return match.group(1) + match.group(2).upper()
|
||||
|
||||
# Ensure first character is capitalized
|
||||
text = text.strip()
|
||||
if text:
|
||||
text = text[0].upper() + text[1:]
|
||||
|
||||
text = capitalize_self_i(text)
|
||||
text = add_space_after_punctuation(text)
|
||||
# Capitalize after sentence-ending punctuation followed by space(s)
|
||||
text = re.sub(r'([.!?]\s+)([a-z])', capitalize_sentences, text)
|
||||
return text
|
||||
|
||||
|
||||
def unicode_to_ascii(text: str) -> str:
|
||||
"""
|
||||
Converts text with accented or special Latin characters (e.g., ó, ñ, ū, ō)
|
||||
into their closest ASCII equivalents.
|
||||
"""
|
||||
# Normalize the string to NFKD to separate base characters from diacritics
|
||||
normalized = unicodedata.normalize('NFKD', text)
|
||||
|
||||
# Encode to ASCII bytes, ignoring characters that can't be converted
|
||||
ascii_bytes = normalized.encode('ascii', 'ignore')
|
||||
|
||||
# Decode back to string
|
||||
ascii_text = ascii_bytes.decode('ascii')
|
||||
|
||||
return ascii_text
|
||||
|
||||
|
||||
def clean_text(text: str, args) -> str:
|
||||
"""
|
||||
Clean the text based on the provided arguments.
|
||||
"""
|
||||
text = unicode_to_ascii(text)
|
||||
if args.normalize:
|
||||
text = text_normalizer(text)
|
||||
if args.replace_numbers:
|
||||
text = convert_to_spoken(text)
|
||||
text = replace_numbers_with_words(text)
|
||||
if args.lowercase:
|
||||
text = text.lower()
|
||||
if args.remove_punc:
|
||||
text = text.replace("-", " ")
|
||||
text = text.replace("_", " ")
|
||||
text = text.translate(str.maketrans("", "", punctuations))
|
||||
text = drop_punctuations(text)
|
||||
if args.auto_pc:
|
||||
text = add_auto_capitalization(text)
|
||||
return clean_label(text)
|
||||
|
||||
|
||||
def clean_asr_manifest(manifest, text_field, args):
|
||||
for i, item in enumerate(manifest):
|
||||
text = str(item[text_field])
|
||||
manifest[i][f"origin_{text_field}"] = text
|
||||
manifest[i][text_field] = clean_text(text, args)
|
||||
return manifest
|
||||
|
||||
|
||||
def clean_conv_manifest(manifest, text_field, args):
|
||||
new_manifest = []
|
||||
for i, item in enumerate(manifest):
|
||||
conversations = []
|
||||
for turn in item["conversations"]:
|
||||
conversations.append(
|
||||
{
|
||||
"role": turn["role"],
|
||||
"value": clean_text(turn["value"], args),
|
||||
"type": turn.get("type", "text"),
|
||||
}
|
||||
)
|
||||
item["conversations"] = conversations
|
||||
new_manifest.append(item)
|
||||
return manifest
|
||||
|
||||
|
||||
def main(args):
|
||||
text_field = args.text_field
|
||||
manifest_files = Path(args.input_manifest)
|
||||
if manifest_files.is_dir():
|
||||
manifest_files = list(manifest_files.glob(args.pattern))
|
||||
elif manifest_files.is_file():
|
||||
manifest_files = [manifest_files]
|
||||
else:
|
||||
raise ValueError(f"Invalid input manifest path: {args.input_manifest}")
|
||||
|
||||
for manifest_file in manifest_files:
|
||||
print(f"Processing manifest file: {manifest_file}")
|
||||
postfix = "-cleaned"
|
||||
postfix += "_norm" if args.normalize else ""
|
||||
postfix += "_n2w" if args.replace_numbers else ""
|
||||
if args.lowercase and args.remove_punc:
|
||||
postfix += "_noPC"
|
||||
else:
|
||||
postfix += "_lc" if args.lowercase else ""
|
||||
postfix += "_np" if args.remove_punc else ""
|
||||
postfix += "_aPC" if args.auto_pc else ""
|
||||
|
||||
output_manifest = manifest_file.with_name(f"{manifest_file.stem}{postfix}{manifest_file.suffix}")
|
||||
|
||||
if args.output:
|
||||
if args.output.endswith(".json"):
|
||||
if len(manifest_files) > 1:
|
||||
raise ValueError("Output path must be a directory when processing multiple manifest files.")
|
||||
output_manifest = Path(args.output)
|
||||
else:
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
if args.keep_name:
|
||||
output_manifest = output_dir / manifest_file.name
|
||||
else:
|
||||
output_manifest = output_dir / output_manifest.name
|
||||
|
||||
manifest = read_manifest(str(manifest_file))
|
||||
|
||||
if args.format == "asr":
|
||||
manifest = clean_asr_manifest(manifest, text_field, args)
|
||||
elif args.format == "conv":
|
||||
manifest = clean_conv_manifest(manifest, text_field, args)
|
||||
else:
|
||||
raise ValueError(f"Unsupported manifest format: {args.format}")
|
||||
|
||||
write_manifest(str(output_manifest), manifest)
|
||||
print(f"Cleaned manifest saved to {output_manifest}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
main(args)
|
||||
@@ -0,0 +1,46 @@
|
||||
|
||||
output_dir: ???
|
||||
|
||||
data:
|
||||
pattern: "*.json"
|
||||
manifest_filepath: ???
|
||||
tarred_audio_filepaths: null
|
||||
sample_rate: 16000
|
||||
max_duration: 30 # you may need to update it for your dataset
|
||||
min_duration: 0.1
|
||||
batch_duration: 300 # you may disable batch_duration by setting it to `null`
|
||||
batch_size: null
|
||||
shuffle: false
|
||||
seed: 42
|
||||
num_workers: 8
|
||||
pin_memory: true
|
||||
quadratic_duration: 30
|
||||
num_buckets: 30
|
||||
num_cuts_for_bins_estimate: 10000
|
||||
bucket_buffer_size: 10000
|
||||
shuffle_buffer_size: 10000
|
||||
|
||||
random_padding:
|
||||
prob: 1.0
|
||||
min_pad_duration: 0.0 # minimum duration of pre/post padding in seconds
|
||||
max_pad_duration: 3.0 # maximum duration of pre/post padding in seconds
|
||||
max_total_duration: 40.0 # maximum total duration of the padded audio in seconds
|
||||
pad_distribution: 'constant' # distribution of padding duration, 'uniform' or 'normal' or 'constant'
|
||||
pre_pad_duration: 0.2
|
||||
post_pad_duration: 3.0
|
||||
|
||||
augmentor:
|
||||
white_noise:
|
||||
prob: 0.0
|
||||
min_level: -90
|
||||
max_level: -40
|
||||
gain:
|
||||
prob: 0.0
|
||||
min_gain_dbfs: -10.0
|
||||
max_gain_dbfs: 10.0
|
||||
noise:
|
||||
prob: 1.0
|
||||
manifest_path: ???
|
||||
min_snr_db: 0
|
||||
max_snr_db: 20
|
||||
max_gain_db: 300.0
|
||||
@@ -0,0 +1,176 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script calculates the EOU metrics using predictions and references in SegLST format.
|
||||
|
||||
Example usage:
|
||||
|
||||
The PREDICTION_ROOT and REFERENCE_ROOT directories should have the following structure:
|
||||
|
||||
<PREDICTION_ROOT>:
|
||||
->dataset1/
|
||||
-> sample1.json
|
||||
-> sample2.json
|
||||
->dataset2/
|
||||
-> sample1.json
|
||||
-> sample2.json
|
||||
|
||||
<REFERENCE_ROOT>:
|
||||
->dataset1/
|
||||
-> sample1.json
|
||||
-> sample2.json
|
||||
->dataset2/
|
||||
-> sample1.json
|
||||
-> sample2.json
|
||||
|
||||
|
||||
each sample.json should contain a list of dictionaries with the following fields:
|
||||
{
|
||||
"session_id": str,
|
||||
"start_time": float, # start time in seconds
|
||||
"end_time": float, # end time in seconds
|
||||
"words": str, # transcription of the utterance
|
||||
"audio_filepath": str, # only in prediction
|
||||
"eou_prob": float, # only in prediction, probability of EOU in range [0.1]
|
||||
"eou_pred": bool, # only in prediction
|
||||
"full_text": str, # only in prediction, which is the full transcription up to the end_time
|
||||
}
|
||||
|
||||
```bash
|
||||
python eval_eou_metrics.py \
|
||||
--prediction $PREDICTION_ROOT \
|
||||
--reference $REFERENCE_ROOT \
|
||||
--multiple
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from nemo.collections.asr.parts.utils.eou_utils import EOUResult, aggregate_eou_metrics, evaluate_eou
|
||||
|
||||
parser = argparse.ArgumentParser(description="Evaluate end of utterance predictions against reference labels.")
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--prediction",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to the directory containing the predictions.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--reference",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to the directory containing the groundtruth.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--eob",
|
||||
action="store_true",
|
||||
help="Whether to evaluate end of backchannel predictions.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore_eob",
|
||||
action="store_true",
|
||||
help="Whether to ignore end of backchannel predictions.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--multiple",
|
||||
action="store_true",
|
||||
help="Whether to evaluate multiple datasets.",
|
||||
)
|
||||
|
||||
|
||||
def load_segLST(directory: str, use_eob: bool = False, ignore_eob: bool = False) -> dict:
|
||||
json_files = list(Path(directory).glob("*.json"))
|
||||
segLST = {}
|
||||
for json_file in json_files:
|
||||
key = json_file.stem
|
||||
with open(json_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
assert isinstance(data, list), f"Data in {json_file} is not a list."
|
||||
if not ignore_eob:
|
||||
# get the data with the correct eob label
|
||||
data = [x for x in data if (x.get("is_backchannel", False) == use_eob)]
|
||||
segLST[key] = data
|
||||
return segLST
|
||||
|
||||
|
||||
def evaluate_eou_predictions(
|
||||
prediction_dir: str, reference_dir: str, use_eob: bool = False, ignore_eob: bool = False
|
||||
) -> List[EOUResult]:
|
||||
prediction_segLST = load_segLST(prediction_dir, use_eob, ignore_eob)
|
||||
reference_segLST = load_segLST(reference_dir, use_eob, ignore_eob)
|
||||
|
||||
eou_metrics = []
|
||||
for key, reference in reference_segLST.items():
|
||||
if key not in prediction_segLST:
|
||||
raise ValueError(f"Key {key} in reference not found in predictions.")
|
||||
prediction = prediction_segLST[key]
|
||||
eou_result = evaluate_eou(
|
||||
prediction=prediction, reference=reference, threshold=None, collar=0.0, do_sorting=True
|
||||
)
|
||||
eou_metrics.append(eou_result)
|
||||
|
||||
results = aggregate_eou_metrics(eou_metrics)
|
||||
|
||||
# add prefix to the keys of the results
|
||||
prefix = Path(reference_dir).stem
|
||||
prefix += "_eob" if use_eob else "_eou"
|
||||
results = {f"{prefix}_{k}": v for k, v in results.items()}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parser.parse_args()
|
||||
|
||||
prediction_dir = Path(args.prediction)
|
||||
reference_dir = Path(args.reference)
|
||||
|
||||
if not prediction_dir.is_dir():
|
||||
raise ValueError(f"Prediction directory {prediction_dir} does not exist or is not a directory.")
|
||||
if not reference_dir.is_dir():
|
||||
raise ValueError(f"Reference directory {reference_dir} does not exist or is not a directory.")
|
||||
|
||||
if args.multiple:
|
||||
# get all subdirectories in the prediction and reference directories
|
||||
prediction_dirs = sorted([x for x in prediction_dir.glob("*/") if x.is_dir()])
|
||||
reference_dirs = sorted([x for x in reference_dir.glob("*/") if x.is_dir()])
|
||||
if len(prediction_dirs) != len(reference_dirs):
|
||||
raise ValueError(
|
||||
f"Number of prediction directories {len(prediction_dirs)} must match number of reference directories {len(reference_dirs)}."
|
||||
)
|
||||
else:
|
||||
prediction_dirs = [prediction_dir]
|
||||
reference_dirs = [reference_dir]
|
||||
|
||||
for ref_dir, pred_dir in zip(reference_dirs, prediction_dirs):
|
||||
if args.multiple and ref_dir.stem != pred_dir.stem:
|
||||
raise ValueError(
|
||||
f"Reference directory {ref_dir} and prediction directory {pred_dir} must have the same name."
|
||||
)
|
||||
results = evaluate_eou_predictions(
|
||||
prediction_dir=str(pred_dir), reference_dir=str(ref_dir), use_eob=args.eob, ignore_eob=args.ignore_eob
|
||||
)
|
||||
# Print the results
|
||||
print("==========================================")
|
||||
print(f"Evaluation Results for: {pred_dir} against {ref_dir}")
|
||||
for key, value in results.items():
|
||||
print(f"{key}: {value:.4f}")
|
||||
print("==========================================")
|
||||
@@ -0,0 +1,224 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
"""
|
||||
This script is used to generate noisy evaluation data for ASR and end of utterance detection.
|
||||
|
||||
Example usage with a single manifest input:
|
||||
python generate_noisy_eval_data.py \
|
||||
--config-path conf/ \
|
||||
--config-name data \
|
||||
output_dir=/path/to/output \
|
||||
data.manifest_filepath=/path/to/manifest.json \
|
||||
data.seed=42 \
|
||||
data.noise.manifest_path /path/to/noise_manifest.json
|
||||
|
||||
|
||||
Example usage with multiple manifests matching a pattern:
|
||||
python generate_noisy_eval_data.py \
|
||||
--config-path conf/ \
|
||||
--config-name data \
|
||||
output_dir=/path/to/output/dir \
|
||||
data.manifest_filepath=/path/to/manifest/dir/ \
|
||||
data.pattern="*.json" \
|
||||
data.seed=42 \
|
||||
data.noise.manifest_path /path/to/noise_manifest.json
|
||||
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
|
||||
import librosa
|
||||
import lightning.pytorch as pl
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
import yaml
|
||||
from lhotse.cut import MixedCut
|
||||
from omegaconf import DictConfig, ListConfig, OmegaConf, open_dict
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.asr.data.audio_to_eou_label_lhotse import LhotseSpeechToTextBpeEOUDataset
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest, write_manifest
|
||||
from nemo.collections.common.data.lhotse import get_lhotse_dataloader_from_config
|
||||
from nemo.collections.common.parts.preprocessing import parsers
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
@hydra_runner(config_path="conf/", config_name="data")
|
||||
def main(cfg: DictConfig):
|
||||
"""
|
||||
Generate noisy evaluation data for ASR and end of utterance detection.
|
||||
|
||||
Args:
|
||||
cfg: DictConfig object containing the configuration.
|
||||
"""
|
||||
# Seed everything for reproducibility
|
||||
seed = cfg.data.get('seed', None)
|
||||
if seed is None:
|
||||
seed = np.random.randint(0, 2**32 - 1)
|
||||
logging.info(f'No seed provided, using random seed: {seed}')
|
||||
logging.info(f'Setting random seed to {seed}')
|
||||
with open_dict(cfg):
|
||||
cfg.data.seed = seed
|
||||
logging.info(f'Hydra config: {OmegaConf.to_yaml(cfg)}')
|
||||
pl.seed_everything(seed)
|
||||
torch.manual_seed(seed)
|
||||
torch.cuda.manual_seed(seed)
|
||||
torch.cuda.manual_seed_all(seed)
|
||||
np.random.seed(seed)
|
||||
torch.backends.cudnn.deterministic = True
|
||||
torch.backends.cudnn.benchmark = False
|
||||
|
||||
# Patch data config
|
||||
with open_dict(cfg.data):
|
||||
cfg.data.force_finite = True
|
||||
cfg.data.force_map_dataset = True
|
||||
cfg.data.shuffle = False
|
||||
cfg.data.check_tokenizer = False # No need to check tokenizer in LhotseSpeechToTextBpeEOUDataset
|
||||
|
||||
# Make output directory
|
||||
output_dir = Path(cfg.output_dir)
|
||||
if output_dir.exists() and cfg.get('overwrite', False):
|
||||
logging.info(f'Removing existing output directory: {output_dir}')
|
||||
rmtree(output_dir)
|
||||
if not output_dir.exists():
|
||||
logging.info(f'Creating output directory: {output_dir}')
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Dump the config to the output directory
|
||||
config = OmegaConf.to_container(cfg, resolve=True)
|
||||
with open(output_dir / 'config.yaml', 'w') as f:
|
||||
yaml.dump(config, f)
|
||||
logging.info(f'Config dumped to {output_dir / "config.yaml"}')
|
||||
|
||||
if isinstance(cfg.data.manifest_filepath, (list, ListConfig)):
|
||||
manifest_list = [Path(x) for x in cfg.data.manifest_filepath]
|
||||
else:
|
||||
input_manifest_file = Path(cfg.data.manifest_filepath)
|
||||
if input_manifest_file.is_dir():
|
||||
pattern = cfg.data.get('pattern', '*.json')
|
||||
manifest_list = list(input_manifest_file.glob(pattern))
|
||||
if not manifest_list:
|
||||
raise ValueError(f"No files found in {input_manifest_file} matching pattern `{pattern}`")
|
||||
else:
|
||||
manifest_list = [Path(x) for x in str(input_manifest_file).split(",")]
|
||||
|
||||
logging.info(f'Found {len(manifest_list)} manifest files to process...')
|
||||
|
||||
for i, manifest_file in enumerate(manifest_list):
|
||||
logging.info(f'[{i+1}/{len(manifest_list)}] Processing {manifest_file}...')
|
||||
data_cfg = deepcopy(cfg.data)
|
||||
data_cfg.manifest_filepath = str(manifest_file)
|
||||
process_manifest(data_cfg, output_dir)
|
||||
|
||||
|
||||
def process_manifest(data_cfg: DictConfig, output_dir: Path):
|
||||
"""
|
||||
Process a manifest file and generate noisy evaluation data.
|
||||
|
||||
Args:
|
||||
data_cfg: Configuration.
|
||||
output_dir: Output directory.
|
||||
"""
|
||||
# Load the input manifest
|
||||
input_manifest = read_manifest(data_cfg.manifest_filepath)
|
||||
logging.info(f'Found {len(input_manifest)} items in input manifest: {data_cfg.manifest_filepath}')
|
||||
manifest_parent_dir = Path(data_cfg.manifest_filepath).parent
|
||||
if Path(input_manifest[0]["audio_filepath"]).is_absolute():
|
||||
output_audio_dir = output_dir / 'wav'
|
||||
flatten_audio_path = True
|
||||
else:
|
||||
output_audio_dir = output_dir
|
||||
flatten_audio_path = False
|
||||
|
||||
if "random_padding" in data_cfg and data_cfg.random_padding.pad_distribution == "constant":
|
||||
is_constant_padding = True
|
||||
pre_pad_dur = data_cfg.random_padding.pre_pad_duration
|
||||
else:
|
||||
is_constant_padding = False
|
||||
pre_pad_dur = None
|
||||
|
||||
# Load the dataset
|
||||
tokenizer = parsers.make_parser() # dummy tokenizer
|
||||
dataset = LhotseSpeechToTextBpeEOUDataset(cfg=data_cfg, tokenizer=tokenizer, return_cuts=True)
|
||||
|
||||
dataloader = get_lhotse_dataloader_from_config(
|
||||
config=data_cfg,
|
||||
global_rank=0,
|
||||
world_size=1,
|
||||
dataset=dataset,
|
||||
tokenizer=tokenizer,
|
||||
)
|
||||
|
||||
# Generate noisy evaluation data
|
||||
manifest = []
|
||||
for i, batch in enumerate(tqdm(dataloader, desc="Generating noisy evaluation data")):
|
||||
audio_batch, audio_len_batch, cuts_batch = batch
|
||||
audio_batch = audio_batch.cpu().numpy()
|
||||
audio_len_batch = audio_len_batch.cpu().numpy()
|
||||
|
||||
for j in range(len(cuts_batch)):
|
||||
cut = cuts_batch[j]
|
||||
if isinstance(cut, MixedCut):
|
||||
cut = cut.first_non_padding_cut
|
||||
|
||||
manifest_item = {}
|
||||
for k, v in cut.custom.items():
|
||||
if k == "dataloading_info":
|
||||
continue
|
||||
manifest_item[k] = v
|
||||
audio = audio_batch[j][: audio_len_batch[j]]
|
||||
audio_file = cut.recording.sources[0].source
|
||||
|
||||
if flatten_audio_path:
|
||||
output_audio_file = output_audio_dir / str(audio_file).replace('/', '_')[:255] # type: Path
|
||||
else:
|
||||
output_audio_file = output_audio_dir / Path(audio_file).relative_to(manifest_parent_dir) # type: Path
|
||||
|
||||
output_audio_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
sf.write(output_audio_file, audio, dataset.sample_rate)
|
||||
|
||||
manifest_item["audio_filepath"] = str(output_audio_file.relative_to(output_audio_dir))
|
||||
manifest_item["offset"] = 0
|
||||
manifest_item["duration"] = audio.shape[0] / dataset.sample_rate
|
||||
|
||||
if is_constant_padding:
|
||||
# Adjust the sou_time and eou_time for constant padding
|
||||
if 'sou_time' in manifest_item and 'eou_time' in manifest_item:
|
||||
if not isinstance(manifest_item['sou_time'], list):
|
||||
manifest_item['sou_time'] = manifest_item['sou_time'] + pre_pad_dur
|
||||
manifest_item['eou_time'] = manifest_item['eou_time'] + pre_pad_dur
|
||||
else:
|
||||
manifest_item['sou_time'] = [x + pre_pad_dur for x in manifest_item['sou_time']]
|
||||
manifest_item['eou_time'] = [x + pre_pad_dur for x in manifest_item['eou_time']]
|
||||
else:
|
||||
# add sou_time and eou_time to the manifest item
|
||||
manifest_item['sou_time'] = pre_pad_dur
|
||||
manifest_item['eou_time'] = pre_pad_dur + librosa.get_duration(filename=audio_file)
|
||||
|
||||
manifest.append(manifest_item)
|
||||
|
||||
# Write the output manifest
|
||||
output_manifest_file = output_dir / Path(data_cfg.manifest_filepath).name
|
||||
write_manifest(output_manifest_file, manifest)
|
||||
logging.info(f'Output manifest written to {output_manifest_file}')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,214 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import os
|
||||
|
||||
os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = "python"
|
||||
import logging
|
||||
import sys
|
||||
import tempfile
|
||||
from argparse import ArgumentParser
|
||||
from pathlib import Path
|
||||
|
||||
import sentencepiece as spm
|
||||
|
||||
from nemo.collections.asr.data.audio_to_eou_label_lhotse import EOB_STRING, EOU_STRING
|
||||
from nemo.core.connectors.save_restore_connector import SaveRestoreConnector
|
||||
|
||||
try:
|
||||
import sentencepiece_model_pb2 as spt
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
raise Exception("Ensure that sentencepiece_model_pb2.py has been generated from the protoc compiler")
|
||||
|
||||
|
||||
SPECIAL_TOKENS = [EOU_STRING, EOB_STRING]
|
||||
|
||||
"""Utility to add special tokens to existing sentencepiece models.
|
||||
|
||||
Generate sentencepiece_model_pb2.py in the directory of this script before running
|
||||
To generate run `protoc --python_out=<path_to_NeMo>/scripts/asr_end_of_utterance/tokenizers sentencepiece_model.proto`
|
||||
inside the src folder in sentencepiece repo
|
||||
Refer: https://github.com/google/sentencepiece/issues/121
|
||||
|
||||
Usage:
|
||||
python add_special_tokens_to_sentencepiece.py \
|
||||
--input_file your_model.nemo \
|
||||
--output_dir /path/to/new/tokenizer_dir/
|
||||
"""
|
||||
|
||||
|
||||
parser = ArgumentParser(description="Add special tokens to sentencepiece model")
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--input_file",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to nemo model file, or sentencepiece model file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output_dir",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to output directory for new tokenizer",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tokens",
|
||||
type=str,
|
||||
nargs='+',
|
||||
help="Special tokens to add to tokenizer",
|
||||
default=SPECIAL_TOKENS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extract_only",
|
||||
action="store_true",
|
||||
help="Extract tokenizer without adding special tokens",
|
||||
)
|
||||
|
||||
|
||||
def extract_nemo_tokenizer(nemo_filepath: str, output_dir: Path) -> str:
|
||||
"""
|
||||
Extract a tokenizer from a Nemo file.
|
||||
Args:
|
||||
nemo_filepath: Path to the Nemo file.
|
||||
output_dir: Path to the output directory.
|
||||
|
||||
Returns:
|
||||
tokenizer: Path to the tokenizer file.
|
||||
"""
|
||||
SaveRestoreConnector._unpack_nemo_file(path2file=nemo_filepath, out_folder=output_dir)
|
||||
tokenizer = None
|
||||
for file in Path(output_dir).glob("**/*"):
|
||||
if file.is_file() and file.name.endswith("tokenizer.model"):
|
||||
tokenizer = file
|
||||
break
|
||||
if tokenizer is None:
|
||||
raise ValueError(f"Tokenizer not found in {output_dir}: {os.listdir(output_dir)}")
|
||||
return str(tokenizer.absolute())
|
||||
|
||||
|
||||
def edit_spt_model(input_file, output_dir, tokens, is_userdefined, extract_only=False):
|
||||
"""
|
||||
Edit a sentencepiece model to add special tokens.
|
||||
Args:
|
||||
input_file: Path to the input sentencepiece model file.
|
||||
output_dir: Path to the output directory.
|
||||
tokens: List of special tokens to add.
|
||||
is_userdefined: Whether the special tokens are user-defined.
|
||||
extract_only: Whether to extract the tokenizer only.
|
||||
"""
|
||||
|
||||
if extract_only:
|
||||
logging.info("Extracting tokenizer only, no special tokens will be added.")
|
||||
|
||||
output_dir = Path(output_dir)
|
||||
|
||||
if output_dir.exists():
|
||||
logging.warning(f"Output directory {output_dir} already exists. Overwriting it.")
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_file = str(output_dir / "tokenizer.model")
|
||||
|
||||
token_type = 3
|
||||
if is_userdefined:
|
||||
token_type = 4
|
||||
|
||||
model = spt.ModelProto()
|
||||
with open(input_file, 'rb') as f:
|
||||
model.ParseFromString(f.read())
|
||||
|
||||
if not extract_only:
|
||||
for token in tokens:
|
||||
piece = model.SentencePiece(piece=token, score=0.0, type=token_type)
|
||||
if piece in model.pieces:
|
||||
logging.error(f"Special Token '{token}' already exists in the input model!")
|
||||
sys.exit(1)
|
||||
model.pieces.append(piece)
|
||||
|
||||
sp = spm.SentencePieceProcessor()
|
||||
sp.LoadFromSerializedProto(model.SerializeToString())
|
||||
|
||||
if not extract_only:
|
||||
try:
|
||||
for token in tokens:
|
||||
id = sp.piece_to_id(token)
|
||||
logging.info(f"Created token '{token}' at ID {id}")
|
||||
logging.info(f"New tokenizer vocab size: {sp.get_piece_size()}")
|
||||
except Exception:
|
||||
logging.error(
|
||||
"Could not appropriately configure new tokenizer. Verify if the special tokens already exist."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
with open(output_file, 'wb') as outf:
|
||||
outf.write(model.SerializeToString())
|
||||
logging.info(f"Created new tokenizer at: {output_file}")
|
||||
|
||||
# Write the vocab to file
|
||||
vocab_file = str(output_dir / "tokenizer.vocab")
|
||||
with open(vocab_file, "w", encoding="utf-8") as f:
|
||||
for i in range(sp.get_piece_size()):
|
||||
piece = sp.id_to_piece(i)
|
||||
score = sp.get_score(i) # Optional: only available if using newer SentencePiece versions
|
||||
f.write(f"{piece}\t{score}\n") # Format follows the original vocab format
|
||||
logging.info(f"Created new tokenizer vocab at: {vocab_file}")
|
||||
|
||||
special_tokens = ["<s>", "</s>", "<pad>", "<unk>"]
|
||||
special_tokens.extend(tokens)
|
||||
vocab_txt_file = str(output_dir / "vocab.txt")
|
||||
with open(vocab_txt_file, "w", encoding="utf-8") as f:
|
||||
for i in range(sp.get_piece_size()):
|
||||
piece = sp.id_to_piece(i)
|
||||
if piece in special_tokens:
|
||||
# skip special tokens
|
||||
continue
|
||||
token = piece[1:] if piece.startswith("▁") else f"##{piece}"
|
||||
if len(token) > 0:
|
||||
f.write(f"{token}\n") # Format follows the original vocab format
|
||||
logging.info(f"Created new tokenizer vocab at: {vocab_txt_file}")
|
||||
|
||||
|
||||
def inject_special_tokens(input_file, output_dir, tokens, is_userdefined=True, extract_only=False):
|
||||
"""
|
||||
Inject special tokens into a sentencepiece model.
|
||||
NOTE: is_userdefined should be set to True in order for ASR model to work with the new special tokens properly.
|
||||
|
||||
Args:
|
||||
input_file: Path to the input sentencepiece model file.
|
||||
output_dir: Path to the output directory.
|
||||
tokens: List of special tokens to add.
|
||||
is_userdefined: Whether the special tokens are user-defined.
|
||||
extract_only: Whether to extract the tokenizer only.
|
||||
"""
|
||||
|
||||
if not os.path.exists(input_file):
|
||||
raise ValueError(f"Input file {input_file} does not exist")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Check if input file is a Nemo file
|
||||
if input_file.endswith(".nemo"):
|
||||
input_file = extract_nemo_tokenizer(input_file, temp_dir)
|
||||
logging.info(f"Extracted tokenizer from Nemo file: {input_file}")
|
||||
else:
|
||||
input_file = os.path.abspath(input_file)
|
||||
logging.info(f"Using input file: {input_file}")
|
||||
|
||||
edit_spt_model(input_file, output_dir, tokens, is_userdefined, extract_only=extract_only)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
args = parser.parse_args()
|
||||
inject_special_tokens(args.input_file, args.output_dir, args.tokens, extract_only=args.extract_only)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,343 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Add the examples/asr directory to the Python path so that we can import the transcribe_speech.py file
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
nemo_root = Path(__file__).parent.parent.parent
|
||||
asr_examples_dir = nemo_root / "examples" / "asr"
|
||||
sys.path.insert(0, str(asr_examples_dir))
|
||||
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from math import ceil
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from omegaconf import ListConfig
|
||||
from tqdm import tqdm
|
||||
from transcribe_speech import TranscriptionConfig as SingleTranscribeConfig # type: ignore
|
||||
from transcribe_speech import main as single_transcribe_main # type: ignore
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest, write_manifest
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging
|
||||
|
||||
"""
|
||||
Transcribe audio manifests on distributed GPUs. Useful for transcription of moderate amounts of audio data.
|
||||
This script also supports splitting the manifest into chunks and merging the results back together.
|
||||
This script is a modified version of `transcribe_speech.py` that only takes manifest files as input.
|
||||
It is useful for transcribing a large amount of audio data that does not fit into a single job.
|
||||
|
||||
# Arguments
|
||||
model_path: path to .nemo ASR checkpoint
|
||||
pretrained_name: name of pretrained ASR model (from NGC registry)
|
||||
dataset_manifest: path to dataset JSON manifest file (in NeMo formats), can be a comma-separated list of manifest files
|
||||
or a directory containing manifest files
|
||||
pattern: pattern to glob the manifest files if `dataset_manifest` is a directory
|
||||
output_dir: directory to write the transcriptions
|
||||
|
||||
compute_langs: Bool to request language ID information (if the model supports it)
|
||||
timestamps: Bool to request greedy time stamp information (if the model supports it) by default None
|
||||
|
||||
(Optionally: You can limit the type of timestamp computations using below overrides)
|
||||
ctc_decoding.ctc_timestamp_type="all" # (default all, can be [all, char, word, segment])
|
||||
rnnt_decoding.rnnt_timestamp_type="all" # (default all, can be [all, char, word, segment])
|
||||
|
||||
output_filename: Output filename where the transcriptions will be written
|
||||
batch_size: batch size during inference
|
||||
presort_manifest: sorts the provided manifest by audio length for faster inference (default: True)
|
||||
|
||||
cuda: Optional int to enable or disable execution of model on certain CUDA device.
|
||||
allow_mps: Bool to allow using MPS (Apple Silicon M-series GPU) device if available
|
||||
amp: Bool to decide if Automatic Mixed Precision should be used during inference
|
||||
audio_type: Str filetype of the audio. Supported = wav, flac, mp3
|
||||
|
||||
overwrite_transcripts: Bool which when set allows repeated transcriptions to overwrite previous results.
|
||||
|
||||
ctc_decoding: Decoding sub-config for CTC. Refer to documentation for specific values.
|
||||
rnnt_decoding: Decoding sub-config for RNNT. Refer to documentation for specific values.
|
||||
|
||||
calculate_wer: Bool to decide whether to calculate wer/cer at end of this script
|
||||
clean_groundtruth_text: Bool to clean groundtruth text
|
||||
langid: Str used for convert_num_to_words during groundtruth cleaning
|
||||
use_cer: Bool to use Character Error Rate (CER) or Word Error Rate (WER)
|
||||
|
||||
calculate_rtfx: Bool to calculate the RTFx throughput to transcribe the input dataset.
|
||||
|
||||
# Usage
|
||||
ASR model can be specified by either "model_path" or "pretrained_name".
|
||||
append_pred - optional. Allows you to add more than one prediction to an existing .json
|
||||
pred_name_postfix - optional. The name you want to be written for the current model
|
||||
Results are returned in a JSON manifest file.
|
||||
|
||||
```bash
|
||||
CUDA_VISIBLE_DEVICES=1 python transcribe_speech_distributed.py \
|
||||
model_path=<path to .nemo ASR checkpoint> \
|
||||
dataset_manifest="<remove or path to manifest>" \
|
||||
output_dir="<output directory>" \
|
||||
output_filename="<remove or specify output filename>" \
|
||||
clean_groundtruth_text=True \
|
||||
langid='en' \
|
||||
batch_size=32 \
|
||||
timestamps=False \
|
||||
compute_langs=False \
|
||||
amp=True \
|
||||
append_pred=False \
|
||||
pred_name_postfix="<remove or use another model name for output filename>" \
|
||||
split_size=10000 \
|
||||
num_nodes=1 \
|
||||
node_idx=0 \
|
||||
num_gpus_per_node=1 \
|
||||
gpu_idx=0
|
||||
```
|
||||
|
||||
If you use Slurm, you can use this params to configure the script:
|
||||
```bash
|
||||
gpu_idx=\$SLURM_LOCALID \
|
||||
num_gpus_per_node=\$SLURM_GPUS_ON_NODE \
|
||||
num_nodes=\$SLURM_JOB_NUM_NODES \
|
||||
node_idx=\$SLURM_NODEID
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptionConfig(SingleTranscribeConfig):
|
||||
"""
|
||||
Transcription Configuration for audio to text transcription.
|
||||
"""
|
||||
|
||||
# General configs
|
||||
pattern: str = "*.json"
|
||||
output_dir: str = "transcribe_output/"
|
||||
|
||||
# Distributed config
|
||||
num_nodes: int = 1 # total number of nodes
|
||||
node_idx: int = 0 # index of the current node
|
||||
num_gpus_per_node: int = 1 # number of GPUs per node
|
||||
gpu_idx: int = 0 # index of the current GPU
|
||||
bind_gpu_to_cuda: bool = (
|
||||
False # If False, the script will just do .cuda() on the model, otherwise it will do .to(f"cuda:{gpu_idx}")
|
||||
)
|
||||
|
||||
# handle long manifest
|
||||
split_size: int = -1 # -1 means no split, otherwise split the manifest into chunks of this size
|
||||
|
||||
|
||||
def get_unfinished_manifest(manifest_list: List[Path], output_dir: Path):
|
||||
"""
|
||||
Get the manifest files that have not finished processing yet, including those that are partly processed.
|
||||
|
||||
Args:
|
||||
manifest_list: List of manifest files to process.
|
||||
output_dir: Directory to write the transcriptions.
|
||||
|
||||
Returns:
|
||||
List of manifest files that have not finished processing yet.
|
||||
"""
|
||||
unfinished = []
|
||||
for manifest_file in manifest_list:
|
||||
output_manifest_file = output_dir / manifest_file.name
|
||||
if not output_manifest_file.exists():
|
||||
unfinished.append(manifest_file)
|
||||
return sorted(unfinished)
|
||||
|
||||
|
||||
def get_manifest_for_current_rank(
|
||||
manifest_list: List[Path], gpu_id: int = 0, num_gpu: int = 1, node_idx: int = 0, num_node: int = 1
|
||||
):
|
||||
"""
|
||||
Get the manifest files for the current rank.
|
||||
|
||||
Args:
|
||||
manifest_list: List of manifest files to process.
|
||||
gpu_id: ID of the current GPU.
|
||||
num_gpu: Number of GPUs per node.
|
||||
node_idx: Index of the current node.
|
||||
num_node: Total number of nodes.
|
||||
|
||||
Returns:
|
||||
List of manifest files for the current rank.
|
||||
"""
|
||||
node_manifest_list = []
|
||||
assert num_node > 0, f"num_node ({num_node}) must be greater than 0"
|
||||
assert num_gpu > 0, f"num_gpu ({num_gpu}) must be greater than 0"
|
||||
assert 0 <= gpu_id < num_gpu, f"gpu_id ({gpu_id}) must be in range [0, {num_gpu})"
|
||||
assert 0 <= node_idx < num_node, f"node_idx ({node_idx}) must be in range [0, {num_node})"
|
||||
for i, manifest_file in enumerate(manifest_list):
|
||||
if (i + node_idx) % num_node == 0:
|
||||
node_manifest_list.append(manifest_file)
|
||||
|
||||
gpu_manifest_list = []
|
||||
for i, manifest_file in enumerate(node_manifest_list):
|
||||
if (i + gpu_id) % num_gpu == 0:
|
||||
gpu_manifest_list.append(manifest_file)
|
||||
return gpu_manifest_list
|
||||
|
||||
|
||||
def maybe_split_manifest(manifest_list: List[Path], cfg: TranscriptionConfig) -> List[Path]:
|
||||
"""
|
||||
Split the manifest files into chunks of the specified size.
|
||||
|
||||
Args:
|
||||
manifest_list: List of manifest files to process.
|
||||
cfg: Configuration.
|
||||
|
||||
Returns:
|
||||
List of sharded manifest files.
|
||||
"""
|
||||
if cfg.split_size is None or cfg.split_size <= 0:
|
||||
return manifest_list
|
||||
|
||||
all_sharded_manifest_files = []
|
||||
sharded_manifest_dir = Path(cfg.output_dir) / "sharded_manifest_todo"
|
||||
sharded_manifest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sharded_manifest_done_dir = Path(cfg.output_dir) / "sharded_manifest_done"
|
||||
sharded_manifest_done_dir.mkdir(parents=True, exist_ok=True)
|
||||
cfg.output_dir = sharded_manifest_done_dir
|
||||
|
||||
logging.info(f"Splitting {len(manifest_list)} manifest files by every {cfg.split_size} samples.")
|
||||
for manifest_file in tqdm(manifest_list, total=len(manifest_list), desc="Splitting manifest files"):
|
||||
manifest = read_manifest(manifest_file)
|
||||
|
||||
num_chunks = ceil(len(manifest) / cfg.split_size)
|
||||
for i in range(num_chunks):
|
||||
chunk_manifest = manifest[i * cfg.split_size : (i + 1) * cfg.split_size]
|
||||
sharded_manifest_file = sharded_manifest_dir / f"{manifest_file.stem}--tpart_{i}.json"
|
||||
write_manifest(sharded_manifest_file, chunk_manifest)
|
||||
all_sharded_manifest_files.append(sharded_manifest_file)
|
||||
|
||||
return all_sharded_manifest_files
|
||||
|
||||
|
||||
def maybe_merge_manifest(cfg: TranscriptionConfig):
|
||||
"""
|
||||
Merge the sharded manifest files back into the original manifest files and write them to the output directory.
|
||||
|
||||
Args:
|
||||
cfg: Configuration.
|
||||
|
||||
Returns:
|
||||
None.
|
||||
"""
|
||||
if cfg.split_size is None or cfg.split_size <= 0:
|
||||
return
|
||||
|
||||
# only merge manifest on the first GPU of the first node
|
||||
if not (cfg.gpu_idx == 0 and cfg.node_idx == 0):
|
||||
return
|
||||
|
||||
sharded_manifest_dir = Path(cfg.output_dir)
|
||||
sharded_manifests = list(sharded_manifest_dir.glob("*--tpart_*.json"))
|
||||
if not sharded_manifests:
|
||||
logging.info(f"No sharded manifest files found in {sharded_manifest_dir}")
|
||||
return
|
||||
|
||||
logging.info(f"Merging {len(sharded_manifests)} sharded manifest files.")
|
||||
manifest_dict = defaultdict(list)
|
||||
for sharded_manifest in sharded_manifests:
|
||||
data_name = sharded_manifest.stem.split("--tpart_")[0]
|
||||
manifest_dict[data_name].append(sharded_manifest)
|
||||
|
||||
output_dir = Path(cfg.output_dir).parent
|
||||
for data_name, sharded_manifest_list in tqdm(
|
||||
manifest_dict.items(), total=len(manifest_dict), desc="Merging manifest files"
|
||||
):
|
||||
merged_manifest = []
|
||||
for sharded_manifest in sharded_manifest_list:
|
||||
manifest = read_manifest(sharded_manifest)
|
||||
merged_manifest.extend(manifest)
|
||||
output_manifest = output_dir / f"{data_name}.json"
|
||||
write_manifest(output_manifest, merged_manifest)
|
||||
logging.info(f"Merged manifest files saved to {output_dir}")
|
||||
|
||||
|
||||
@hydra_runner(config_name="TranscriptionConfig", schema=TranscriptionConfig)
|
||||
def run_distributed_transcribe(cfg: TranscriptionConfig):
|
||||
"""
|
||||
Run distributed transcription with the given configuration.
|
||||
"""
|
||||
logging.info(f"Running distributed transcription with config: {cfg}")
|
||||
|
||||
if cfg.dataset_manifest is None:
|
||||
raise ValueError("`dataset_manifest` is required")
|
||||
|
||||
# load the manifest
|
||||
if isinstance(cfg.dataset_manifest, str) and "," in cfg.dataset_manifest:
|
||||
manifest_list = cfg.dataset_manifest.split(",")
|
||||
elif isinstance(cfg.dataset_manifest, (ListConfig, list)):
|
||||
manifest_list = cfg.dataset_manifest
|
||||
else:
|
||||
input_manifest = Path(cfg.dataset_manifest)
|
||||
if input_manifest.is_dir():
|
||||
manifest_list = list(input_manifest.glob(cfg.pattern))
|
||||
elif input_manifest.is_file():
|
||||
manifest_list = [input_manifest]
|
||||
else:
|
||||
raise ValueError(f"Invalid manifest file or directory: {input_manifest}")
|
||||
|
||||
if not manifest_list:
|
||||
raise ValueError(f"No manifest files found matching pattern: {cfg.pattern} in {input_manifest}")
|
||||
|
||||
manifest_list = maybe_split_manifest(manifest_list, cfg)
|
||||
original_manifest_list = list(manifest_list)
|
||||
logging.info(f"Found {len(manifest_list)} manifest files.")
|
||||
|
||||
output_dir = Path(cfg.output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
unfinished_manifest = get_unfinished_manifest(manifest_list, output_dir=output_dir)
|
||||
if not unfinished_manifest:
|
||||
maybe_merge_manifest(cfg)
|
||||
logging.info("All manifest files have been processed. Exiting.")
|
||||
return
|
||||
logging.info(f"Found {len(unfinished_manifest)} unfinished manifest files.")
|
||||
|
||||
manifest_list = get_manifest_for_current_rank(
|
||||
unfinished_manifest,
|
||||
gpu_id=cfg.gpu_idx,
|
||||
num_gpu=cfg.num_gpus_per_node,
|
||||
node_idx=cfg.node_idx,
|
||||
num_node=cfg.num_nodes,
|
||||
)
|
||||
if not manifest_list:
|
||||
logging.info(f"No manifest files found for GPU {cfg.gpu_idx} on node {cfg.node_idx}. Exiting.")
|
||||
return
|
||||
|
||||
logging.info(f"Processing {len(manifest_list)} manifest files with GPU {cfg.gpu_idx} on node {cfg.node_idx}.")
|
||||
|
||||
cfg.cuda = cfg.gpu_idx if cfg.bind_gpu_to_cuda else None
|
||||
for manifest_file in tqdm(manifest_list):
|
||||
logging.info(f"Processing {manifest_file}...")
|
||||
output_filename = output_dir / Path(manifest_file).name
|
||||
curr_cfg = deepcopy(cfg)
|
||||
curr_cfg.dataset_manifest = str(manifest_file)
|
||||
curr_cfg.output_filename = str(output_filename)
|
||||
|
||||
single_transcribe_main(curr_cfg)
|
||||
|
||||
# check if all manifest files have been processed
|
||||
unfinished_manifest = get_unfinished_manifest(original_manifest_list, output_dir=output_dir)
|
||||
if not unfinished_manifest:
|
||||
maybe_merge_manifest(cfg)
|
||||
logging.info("All manifest files have been processed. Exiting.")
|
||||
return
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run_distributed_transcribe() # noqa pylint: disable=no-value-for-parameter
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Execution :
|
||||
|
||||
python create_tarred_tokenized_text_lm_dataset.py \
|
||||
--text_path=<comma seperated text filepaths> \
|
||||
--data_root=<path to output directory> \
|
||||
--tokenizer_name="bert-base-cased" \
|
||||
--tokenizer_vocab_file=<path to vocab file for tokenizer> \
|
||||
--num_shards=64 \
|
||||
--chunk_size=8192 \
|
||||
--chunk_write_buffer=512 \
|
||||
--lower_case \
|
||||
--log
|
||||
"""
|
||||
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tarfile
|
||||
|
||||
import joblib
|
||||
import numpy as np
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.common.tokenizers.tokenizer_utils import get_tokenizer
|
||||
|
||||
parser = argparse.ArgumentParser(description='Tarred Tokenized dataset for text language modelling')
|
||||
|
||||
# Data path arguments
|
||||
parser.add_argument('--text_path', required=True, default=None, type=str, help='Text paths, seperated by commas')
|
||||
parser.add_argument('--data_root', required=True, default=None, type=str, help='Output directory')
|
||||
|
||||
# General arguments
|
||||
parser.add_argument(
|
||||
'--chunk_write_buffer',
|
||||
default=128,
|
||||
type=int,
|
||||
help='Number of chunks of `chunk_size` to buffer for parallel tokenization and serial write to disk',
|
||||
)
|
||||
parser.add_argument('--lower_case', action='store_true', help='Whether to lower case the corpus')
|
||||
parser.add_argument('--log', action='store_true', help='Whether to print logs to terminal')
|
||||
|
||||
# Tokenizer arguments
|
||||
parser.add_argument('--tokenizer_name', required=False, default=None, type=str, help='Tokenizer name for resolution')
|
||||
parser.add_argument(
|
||||
'--tokenizer_model', required=False, default=None, type=str, help='Path to tokenizer model for sentencepiece'
|
||||
)
|
||||
parser.add_argument('--tokenizer_vocab_file', required=False, type=str, default=None, help='Path to a vocab file')
|
||||
parser.add_argument(
|
||||
'--tokenizer_special_tokens', default=None, type=str, nargs='+', help='List of special tokens for the tokenizer'
|
||||
)
|
||||
|
||||
# Tarred dataset arguments
|
||||
parser.add_argument('--num_shards', default=1, type=int, help='Number of shards for the tarfile')
|
||||
parser.add_argument('--chunk_size', default=8192, type=int, help='Number of rows of data concatenated into a vector')
|
||||
|
||||
parser.set_defaults(log=False, lower_case=False)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def __build_dataset_from_text(texts: str, lower_case: bool, chunk_size: int):
|
||||
if ',' in texts:
|
||||
texts = texts.split(',')
|
||||
else:
|
||||
texts = [texts]
|
||||
|
||||
num_lines = 0
|
||||
text_dataset = []
|
||||
|
||||
for text in texts:
|
||||
with open(text, 'r', encoding='utf-8') as in_reader:
|
||||
reader = tqdm(iter(lambda: in_reader.readline(), ''), desc="Read 0 lines", unit=' lines')
|
||||
|
||||
for i, line in enumerate(reader):
|
||||
# Clean text line
|
||||
line = line.replace("\n", "").strip()
|
||||
|
||||
if lower_case:
|
||||
line = line.lower()
|
||||
|
||||
if line:
|
||||
text_dataset.append(line)
|
||||
|
||||
num_lines += 1
|
||||
if num_lines % 100000 == 0:
|
||||
reader.set_description(f"Read {num_lines} lines")
|
||||
|
||||
if num_lines % chunk_size == 0:
|
||||
yield text_dataset, num_lines
|
||||
|
||||
# Empty cache
|
||||
text_dataset = []
|
||||
|
||||
logging.info(f"Finished extracting manifest : {text}")
|
||||
|
||||
logging.info("Finished extracting all manifests ! Number of sentences : {}".format(num_lines))
|
||||
|
||||
if len(text_dataset) != 0:
|
||||
yield text_dataset, num_lines
|
||||
|
||||
|
||||
def __tokenize_str(texts, tokenizer):
|
||||
tokenized_text = []
|
||||
for text in texts:
|
||||
tok_text = tokenizer.text_to_ids(text)
|
||||
tokenized_text.extend(tok_text)
|
||||
return tokenized_text
|
||||
|
||||
|
||||
def __tokenize_text(
|
||||
text_paths, tokenizer, tokenized_cachedir, lower_case: bool = False, chunk_size=8192, write_buffer: int = -1
|
||||
):
|
||||
if write_buffer < 1:
|
||||
write_buffer = max(os.cpu_count() - write_buffer, 1)
|
||||
|
||||
logging.info(f"Using write chunk buffer of size {write_buffer}")
|
||||
|
||||
if not os.path.exists(tokenized_cachedir):
|
||||
os.makedirs(tokenized_cachedir)
|
||||
|
||||
# global parameters
|
||||
global_chunk_idx = 0
|
||||
chunk_paths = []
|
||||
chunk_lens = []
|
||||
|
||||
# buffer parameters
|
||||
data_cache = []
|
||||
chunk_idx = 0
|
||||
|
||||
text_generator = iter(__build_dataset_from_text(text_paths, lower_case=lower_case, chunk_size=chunk_size))
|
||||
global_num_lines = 0
|
||||
last_batch = False
|
||||
|
||||
with joblib.Parallel(n_jobs=-2, verbose=10) as parallel:
|
||||
|
||||
while True:
|
||||
try:
|
||||
data, num_lines = next(text_generator)
|
||||
data_cache.append(data)
|
||||
|
||||
global_num_lines = num_lines
|
||||
except StopIteration:
|
||||
last_batch = True
|
||||
|
||||
# Update counters
|
||||
chunk_idx += 1
|
||||
|
||||
if (chunk_idx == write_buffer) or last_batch:
|
||||
# write the chunks into disk after parallel tokenization
|
||||
tokenized_data_list = parallel(
|
||||
joblib.delayed(__tokenize_str)(chunk, tokenizer) for chunk in data_cache
|
||||
)
|
||||
|
||||
# Sequential write cache
|
||||
for chunk in tokenized_data_list:
|
||||
fp = os.path.join(tokenized_cachedir, f"chunk_{global_chunk_idx}.npy")
|
||||
chunk = np.asarray(chunk, dtype=np.int64)
|
||||
np.save(fp, chunk, allow_pickle=False)
|
||||
|
||||
chunk_paths.append(fp)
|
||||
chunk_lens.append(len(chunk))
|
||||
|
||||
global_chunk_idx += 1
|
||||
|
||||
logging.info(f"Wrote a total of {global_chunk_idx} chunks to file...")
|
||||
|
||||
# reset buffers
|
||||
data_cache.clear()
|
||||
del data_cache
|
||||
|
||||
data_cache = []
|
||||
chunk_idx = 0
|
||||
|
||||
if last_batch:
|
||||
logging.info("Finished tokenizing last chunk")
|
||||
break
|
||||
|
||||
logging.info(
|
||||
f"Chunking {global_num_lines} rows into {global_num_lines // chunk_size} tasks (each chunk contains {chunk_size} elements)"
|
||||
)
|
||||
return chunk_paths, chunk_lens
|
||||
|
||||
|
||||
def __create_chunk(data_root, chunk_path, shard_id, compute_metrics=False):
|
||||
"""Creates a tarball containing the tokenized text chunks."""
|
||||
tar = tarfile.open(os.path.join(data_root, f'text_{shard_id}.tar'), mode='a', encoding='utf-8')
|
||||
|
||||
# We squash the filename since we do not preserve directory structure of tokenized text in the tarball.
|
||||
base, ext = os.path.splitext(chunk_path)
|
||||
base = base.replace(os.pathsep, '_')
|
||||
# Need the following replacement as long as WebDataset splits on first period
|
||||
base = base.replace('.', '_')
|
||||
squashed_filename = f'{base}{ext}'
|
||||
tar.add(chunk_path, arcname=squashed_filename)
|
||||
|
||||
tar.close()
|
||||
|
||||
if compute_metrics:
|
||||
data = np.load(chunk_path, allow_pickle=False)
|
||||
chunk_len = len(data)
|
||||
return (chunk_len,)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def __write_tarred_tokenized_text_dataset(data_root, num_shards, chunk_paths, chunk_lens):
|
||||
num_chunks = len(chunk_paths)
|
||||
|
||||
if chunk_lens is not None:
|
||||
num_text = sum(chunk_lens)
|
||||
shard_counts = {chunk_id: chunk_len for chunk_id, chunk_len in enumerate(chunk_lens)}
|
||||
compute_metrics = False
|
||||
|
||||
else:
|
||||
num_text = 0
|
||||
shard_counts = {}
|
||||
compute_metrics = True
|
||||
|
||||
for chunk_id, chunk_path in enumerate(tqdm(chunk_paths, desc='Writing chunk ', unit=' chunks')):
|
||||
shard_id = chunk_id % num_shards
|
||||
metrics = __create_chunk(data_root, chunk_path, shard_id, compute_metrics=compute_metrics)
|
||||
|
||||
if metrics is not None:
|
||||
num_text += metrics[0]
|
||||
shard_counts[chunk_id] = metrics[0]
|
||||
|
||||
# write metadata
|
||||
metadata_path = os.path.join(data_root, 'metadata.json')
|
||||
with open(metadata_path, 'w') as f:
|
||||
metadata = {'num_chunks': num_chunks, 'num_text': num_text, 'shard_count': shard_counts}
|
||||
json.dump(metadata, f, indent=4)
|
||||
logging.info("Metadata writen..")
|
||||
|
||||
|
||||
def main():
|
||||
text_path = args.text_path
|
||||
data_root = args.data_root
|
||||
|
||||
if args.log:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
tokenized_cachedir = os.path.join(data_root, '_tokenized_dataset_cachedir')
|
||||
|
||||
if os.path.exists(tokenized_cachedir):
|
||||
logging.warning(
|
||||
f'Tokenized cache directory {tokenized_cachedir} already potentially contains files.'
|
||||
f'In such a case, please be aware that the tarfiles will be **appended** instead of overridden!'
|
||||
)
|
||||
|
||||
if not os.path.exists(data_root):
|
||||
os.makedirs(data_root)
|
||||
|
||||
chunk_paths = None
|
||||
chunk_lens = None
|
||||
|
||||
if os.path.exists(tokenized_cachedir):
|
||||
paths = glob.glob(os.path.join(tokenized_cachedir, "*.npy"))
|
||||
if len(paths) > 0:
|
||||
logging.info("Cached tokenized numpy files found, skipping re-tokenization of dataset")
|
||||
|
||||
chunk_paths = paths
|
||||
chunk_lens = None
|
||||
|
||||
if chunk_paths is None:
|
||||
if args.tokenizer_name is None:
|
||||
raise ValueError("`tokenizer_name` name is required when tokenizing the dataset for the first time.")
|
||||
|
||||
if args.tokenizer_vocab_file is None:
|
||||
raise ValueError("`tokenizer_vocab_file` is required when constructing the tokenized dataset")
|
||||
|
||||
tokenizer = get_tokenizer(
|
||||
tokenizer_name=args.tokenizer_name,
|
||||
tokenizer_model=args.tokenizer_model,
|
||||
vocab_file=args.tokenizer_vocab_file,
|
||||
special_tokens=args.tokenizer_special_tokens,
|
||||
)
|
||||
|
||||
logging.info("Built tokenizer")
|
||||
|
||||
# tokenize text data into sub-words
|
||||
chunk_paths, chunk_lens = __tokenize_text(
|
||||
text_paths=text_path,
|
||||
tokenizer=tokenizer,
|
||||
tokenized_cachedir=tokenized_cachedir,
|
||||
lower_case=args.lower_case,
|
||||
chunk_size=args.chunk_size,
|
||||
write_buffer=args.chunk_write_buffer,
|
||||
)
|
||||
logging.info(f"Tokenized dataset into sub-words and serialized cache at {tokenized_cachedir}")
|
||||
|
||||
# Write tarred dataset
|
||||
__write_tarred_tokenized_text_dataset(
|
||||
data_root, num_shards=args.num_shards, chunk_paths=chunk_paths, chunk_lens=chunk_lens
|
||||
)
|
||||
|
||||
logging.info('Done preparing tokenized dataset!')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,326 @@
|
||||
# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script would evaluate a neural language model (Transformer) trained with
|
||||
`examples/nlp/language_modeling/transformer_lm.py' as a rescorer for ASR systems.
|
||||
Given a trained TransformerLMModel `.nemo` file, this script can be used to re-score the beams obtained from a beam
|
||||
search decoder of an ASR model.
|
||||
|
||||
USAGE:
|
||||
1. Obtain `.tsv` file with beams and their corresponding scores. Scores can be from a regular beam search decoder or
|
||||
in fusion with an N-gram LM scores. For a given beam size `beam_size` and a number of examples
|
||||
for evaluation `num_eval_examples`, it should contain (`beam_size` x `num_eval_examples`) lines of
|
||||
form `beam_candidate_text \t score`. This file can be generated by `scripts/asr_language_modeling/ngram_lm/eval_beamsearch_ngram.py`.
|
||||
|
||||
2. Rescore the candidates:
|
||||
python eval_neural_rescorer.py
|
||||
--lm_model=[path to .nemo file of the LM]
|
||||
--beams_file=[path to beams .tsv file]
|
||||
--beam_size=[size of the beams]
|
||||
--eval_manifest=[path to eval manifest .json file]
|
||||
--batch_size=[batch size used for inference on the LM model]
|
||||
--alpha=[the value for the parameter rescorer_alpha]
|
||||
--beta=[the value for the parameter rescorer_beta]
|
||||
|
||||
You may find more info on how to use this script at:
|
||||
https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/asr_language_modeling.html
|
||||
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import inspect
|
||||
import json
|
||||
from abc import ABC
|
||||
from argparse import ArgumentParser
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
import tqdm
|
||||
from kaldialign import edit_distance
|
||||
from transformers import AutoModelForCausalLM
|
||||
|
||||
try:
|
||||
from nemo.collections.nlp.models.language_modeling import TransformerLMModel
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
TransformerLMModel = ABC
|
||||
|
||||
from nemo.collections.common.tokenizers.tokenizer_utils import get_tokenizer
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
class BeamScoresDataset(torch.utils.data.Dataset):
|
||||
"""
|
||||
Dataset to read the score file containing the beams and their score
|
||||
|
||||
Args:
|
||||
data_path: path to the beams file
|
||||
tokenizer: tokenizer of the LM model
|
||||
manifest_path: manifest `.json` file which contains the ground truths transcripts
|
||||
beam_size: the number of beams per sample
|
||||
max_seq_length: the maximum length of sequences
|
||||
"""
|
||||
|
||||
def __init__(self, data_path, tokenizer, manifest_path, beam_size=128, max_seq_length=256):
|
||||
self.data = pd.read_csv(data_path, delimiter="\t", header=None)
|
||||
self.tokenizer = tokenizer
|
||||
self.ground_truths = []
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f_orig:
|
||||
for line in f_orig:
|
||||
item = json.loads(line)
|
||||
self.ground_truths.append(item['text'])
|
||||
self.beam_size = beam_size
|
||||
self.max_seq_length = max_seq_length
|
||||
|
||||
if self.tokenizer.pad_id is not None:
|
||||
self.pad_id = self.tokenizer.pad_id
|
||||
elif self.tokenizer.eos_id is not None:
|
||||
self.pad_id = self.tokenizer.eos_id
|
||||
else:
|
||||
logging.warning(f"Using 0 as pad_id as the tokenizer has no pad_id or eos_id.")
|
||||
self.pad_id = 0
|
||||
|
||||
def __len__(self):
|
||||
return len(self.data)
|
||||
|
||||
def __getitem__(self, idx):
|
||||
text = str(self.data[0][idx])
|
||||
tokens = self.tokenizer.text_to_ids(text)
|
||||
if self.tokenizer.bos_id is not None:
|
||||
tokens = [self.tokenizer.bos_id] + tokens
|
||||
if self.tokenizer.eos_id is not None:
|
||||
tokens = tokens + [self.tokenizer.eos_id]
|
||||
input_ids = [self.pad_id] * self.max_seq_length
|
||||
input_ids[: len(tokens)] = tokens
|
||||
input_ids = np.array(input_ids)
|
||||
input_mask = np.zeros(self.max_seq_length)
|
||||
input_mask[: len(tokens)] = 1
|
||||
acoustic_score = self.data[1][idx]
|
||||
dist = edit_distance(self.ground_truths[idx // self.beam_size].split(), text.split())['total']
|
||||
ref_len = len(self.ground_truths[idx // self.beam_size].split())
|
||||
len_in_chars = len(str(self.data[0][idx]))
|
||||
return input_ids, input_mask, acoustic_score, dist, ref_len, len_in_chars, idx
|
||||
|
||||
|
||||
def linear_search_wer(
|
||||
dists, scores1, scores2, total_len, coef_range=[0, 10], coef_steps=10000, param_name='parameter'
|
||||
):
|
||||
"""
|
||||
performs linear search to find the best coefficient when two set of scores are getting linearly fused.
|
||||
|
||||
Args:
|
||||
dists: Tesnor of the distances between the ground truth and the candidates with shape of [number of samples, beam size]
|
||||
scores1: Tensor of the first set of scores with shape of [number of samples, beam size]
|
||||
scores2: Tensor of the second set of scores with shape of [number of samples, beam size]
|
||||
total_len: The total length of all samples
|
||||
coef_range: the search range for the coefficient
|
||||
coef_steps: the number of steps that the search range would get divided into
|
||||
param_name: the name of the parameter to be used in the figure
|
||||
|
||||
Output:
|
||||
(best coefficient found, best WER achieved)
|
||||
"""
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
scale = scores1.mean().abs().item() / scores2.mean().abs().item()
|
||||
left = coef_range[0] * scale
|
||||
right = coef_range[1] * scale
|
||||
coefs = np.linspace(left, right, coef_steps)
|
||||
|
||||
best_wer = 10000
|
||||
best_coef = left
|
||||
wers = []
|
||||
for coef in coefs:
|
||||
scores = scores1 + coef * scores2
|
||||
wer = compute_wer(dists, scores, total_len)
|
||||
wers.append(wer)
|
||||
if wer < best_wer:
|
||||
best_wer = wer
|
||||
best_coef = coef
|
||||
|
||||
plt.plot(coefs, wers)
|
||||
plt.title(f'WER% after rescoring with different values of {param_name}')
|
||||
plt.ylabel('WER%')
|
||||
plt.xlabel(param_name)
|
||||
plt.show()
|
||||
return best_coef, best_wer
|
||||
|
||||
|
||||
def compute_wer(dists, scores, total_len):
|
||||
"""
|
||||
Sorts the candidates based on the scores and calculates the WER with the new top candidates.
|
||||
|
||||
Args:
|
||||
dists: Tensor of the distances between the ground truth and the candidates with shape of [number of samples, beam size]
|
||||
scores: Tensor of the scores for candidates with shape of [number of samples, beam size]
|
||||
total_len: The total length of all samples
|
||||
|
||||
Output:
|
||||
WER with the new scores
|
||||
"""
|
||||
indices = scores.max(dim=1, keepdim=True)[1]
|
||||
wer = dists.gather(dim=1, index=indices).sum() / total_len
|
||||
wer = wer.item()
|
||||
return wer
|
||||
|
||||
|
||||
def main():
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--lm_model_file",
|
||||
type=str,
|
||||
required=True,
|
||||
help="path to LM model .nemo file or the name of a HuggingFace pretrained models like 'transfo-xl-wt103' or 'gpt2'",
|
||||
)
|
||||
parser.add_argument("--beams_file", type=str, required=True, help="path to beams .tsv file")
|
||||
parser.add_argument(
|
||||
"--eval_manifest", type=str, required=True, help="path to the evaluation `.json` manifest file"
|
||||
)
|
||||
parser.add_argument("--beam_size", type=int, required=True, help="number of beams per candidate")
|
||||
parser.add_argument("--batch_size", type=int, default=256, help="inference batch size")
|
||||
parser.add_argument("--alpha", type=float, default=None, help="parameter alpha of the fusion")
|
||||
parser.add_argument("--beta", type=float, default=None, help="parameter beta of the fusion")
|
||||
parser.add_argument("--max_seq_length", default=512, help="Maximum sequence length (in tokens) for the input")
|
||||
parser.add_argument(
|
||||
"--scores_output_file", default=None, type=str, help="The optional path to store the rescored beams"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--device", default="cuda", type=str, help="The device to load the model onto to calculate the scores"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use_amp", action="store_true", help="Whether to use AMP if available to calculate the scores"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
device = args.device
|
||||
if device.startswith("cuda") and not torch.cuda.is_available():
|
||||
logging.info(f"cuda is not available! switched to cpu.")
|
||||
device = "cpu"
|
||||
|
||||
if args.lm_model_file.endswith(".nemo"):
|
||||
nemo_model = True
|
||||
logging.info("Attempting to initialize from .nemo file...")
|
||||
model = TransformerLMModel.restore_from(
|
||||
restore_path=args.lm_model_file, map_location=torch.device(device)
|
||||
).eval()
|
||||
model_tokenizer = model.tokenizer
|
||||
else:
|
||||
nemo_model = False
|
||||
logging.info("Attempting to initialize from a pretrained model from HuggingFace...")
|
||||
model = (
|
||||
AutoModelForCausalLM.from_pretrained(pretrained_model_name_or_path=args.lm_model_file, is_decoder=True)
|
||||
.to(device)
|
||||
.eval()
|
||||
)
|
||||
model_tokenizer = get_tokenizer(tokenizer_name=args.lm_model_file)
|
||||
|
||||
max_seq_length = args.max_seq_length
|
||||
dataset = BeamScoresDataset(args.beams_file, model_tokenizer, args.eval_manifest, args.beam_size, max_seq_length)
|
||||
data_loader = torch.utils.data.DataLoader(dataset=dataset, batch_size=args.batch_size)
|
||||
|
||||
if "attention_mask" in inspect.getfullargspec(model.forward).args:
|
||||
support_att_mask = True
|
||||
else:
|
||||
support_att_mask = False
|
||||
logging.info(f"Rescoring with beam_size: {args.beam_size}")
|
||||
logging.info("Calculating the scores...")
|
||||
with torch.amp.autocast(model.device.type, enabled=args.use_amp):
|
||||
with torch.no_grad():
|
||||
am_scores, lm_scores, dists, ref_lens, lens_in_chars = [], [], [], [], []
|
||||
for batch in tqdm.tqdm(data_loader):
|
||||
input_ids, input_mask, acoustic_score, dist, ref_len, len_in_chars, idx = batch
|
||||
|
||||
max_len_in_batch = input_mask.sum(dim=0).argmin().item()
|
||||
input_ids, input_mask = input_ids[:, :max_len_in_batch], input_mask[:, :max_len_in_batch]
|
||||
if torch.cuda.is_available():
|
||||
input_ids, input_mask = input_ids.to(device), input_mask.to(device)
|
||||
dist, acoustic_score, len_in_chars = (
|
||||
dist.to(device),
|
||||
acoustic_score.to(device),
|
||||
len_in_chars.to(device),
|
||||
)
|
||||
# some models like Transformer-XL don't need attention_mask as input
|
||||
if support_att_mask:
|
||||
log_probs = model(input_ids=input_ids, attention_mask=input_mask)
|
||||
else:
|
||||
log_probs = model(input_ids=input_ids)
|
||||
|
||||
if not nemo_model:
|
||||
log_probs = torch.nn.functional.log_softmax(log_probs.logits, dim=-1)
|
||||
|
||||
target_log_probs = log_probs[:, :-1].gather(2, input_ids[:, 1:].unsqueeze(2)).squeeze(2)
|
||||
neural_lm_score = torch.sum(target_log_probs * input_mask[:, 1:], dim=-1)
|
||||
|
||||
am_scores.append(acoustic_score)
|
||||
lm_scores.append(neural_lm_score)
|
||||
dists.append(dist)
|
||||
ref_lens.append(ref_len)
|
||||
lens_in_chars.append(len_in_chars)
|
||||
|
||||
am_scores = torch.cat(am_scores).view(-1, args.beam_size)
|
||||
lm_scores = torch.cat(lm_scores).view(-1, args.beam_size)
|
||||
dists = torch.cat(dists).view(-1, args.beam_size)
|
||||
ref_lens = torch.cat(ref_lens).view(-1, args.beam_size)
|
||||
lens_in_chars = torch.cat(lens_in_chars).view(-1, args.beam_size).to(am_scores.dtype)
|
||||
|
||||
total_len = ref_lens[:, 0].sum()
|
||||
model_wer = dists[:, 0].sum() / total_len
|
||||
ideal_wer = dists.min(dim=1)[0].sum() / total_len
|
||||
|
||||
if args.alpha is None:
|
||||
logging.info("Linear search for alpha...")
|
||||
coef1, _ = linear_search_wer(
|
||||
dists=dists, scores1=am_scores, scores2=lm_scores, total_len=total_len, param_name='alpha'
|
||||
)
|
||||
coef1 = np.round(coef1, 3)
|
||||
logging.info(f"alpha={coef1} achieved the best WER.")
|
||||
logging.info(f"------------------------------------------------")
|
||||
else:
|
||||
coef1 = args.alpha
|
||||
|
||||
scores = am_scores + coef1 * lm_scores
|
||||
|
||||
if args.beta is None:
|
||||
logging.info("Linear search for beta...")
|
||||
coef2, _ = linear_search_wer(
|
||||
dists=dists, scores1=scores, scores2=lens_in_chars, total_len=total_len, param_name='beta'
|
||||
)
|
||||
coef2 = np.round(coef2, 3)
|
||||
logging.info(f"beta={coef2} achieved the best WER.")
|
||||
logging.info(f"------------------------------------------------")
|
||||
else:
|
||||
coef2 = args.beta
|
||||
|
||||
new_scores = am_scores + coef1 * lm_scores + coef2 * lens_in_chars
|
||||
rescored_wer = compute_wer(dists, new_scores, total_len)
|
||||
|
||||
logging.info(f"Input beams WER: {np.round(model_wer.item() * 100, 2)}%")
|
||||
logging.info(f"------------------------------------------------")
|
||||
logging.info(f" +LM rescoring WER: {np.round(rescored_wer * 100, 2)}%")
|
||||
logging.info(f" with alpha={coef1}, beta={coef2}")
|
||||
logging.info(f"------------------------------------------------")
|
||||
logging.info(f"Oracle WER: {np.round(ideal_wer.item() * 100, 2)}%")
|
||||
logging.info(f"------------------------------------------------")
|
||||
|
||||
new_scores_flatten = new_scores.flatten()
|
||||
if args.scores_output_file is not None:
|
||||
logging.info(f'Saving the candidates with their new scores at `{args.scores_output_file}`...')
|
||||
with open(args.scores_output_file, "w", encoding='utf-8') as fout:
|
||||
for sample_id in range(len(dataset)):
|
||||
fout.write(f"{dataset.data[0][sample_id]}\t{new_scores_flatten[sample_id]}\n")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Use this file to create a lexicon file for Flashlight decoding from an existing KenLM arpa file
|
||||
# A lexicon file is required for Flashlight decoding in most cases, as it acts as a map from the words
|
||||
# in you arpa file to the representation used by your ASR AM.
|
||||
# For more details, see: https://github.com/flashlight/flashlight/tree/main/flashlight/app/asr#data-preparation
|
||||
#
|
||||
# Usage: python create_lexicon_from_arpa.py --arpa /path/to/english.arpa --model /path/to/model.nemo --lower
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
|
||||
from nemo.utils import logging
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Utility script for generating lexicon file from a KenLM arpa file")
|
||||
parser.add_argument("--arpa", required=True, help="path to your arpa file")
|
||||
parser.add_argument("--dst", help="directory to store generated lexicon", default=None)
|
||||
parser.add_argument("--lower", action='store_true', help="Whether to lowercase the arpa vocab")
|
||||
parser.add_argument("--model", default=None, help="path to Nemo model for its tokeniser")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.arpa):
|
||||
logging.critical(f"ARPA file [ {args.arpa} ] not detected on disk, aborting!")
|
||||
exit(255)
|
||||
|
||||
if args.dst is not None:
|
||||
save_path = args.dst
|
||||
else:
|
||||
save_path = os.path.dirname(args.arpa)
|
||||
os.makedirs(save_path, exist_ok=True)
|
||||
|
||||
tokenizer = None
|
||||
if args.model is not None:
|
||||
from nemo.collections.asr.models import ASRModel
|
||||
|
||||
model = ASRModel.restore_from(restore_path=args.model, map_location='cpu')
|
||||
if hasattr(model, 'tokenizer'):
|
||||
tokenizer = model.tokenizer
|
||||
else:
|
||||
logging.warning('Supplied Nemo model does not contain a tokenizer')
|
||||
|
||||
lex_file = os.path.join(save_path, os.path.splitext(os.path.basename(args.arpa))[0] + '.lexicon')
|
||||
|
||||
logging.info(f"Writing Lexicon file to: {lex_file}...")
|
||||
with open(lex_file, "w", encoding='utf_8', newline='\n') as f:
|
||||
with open(args.arpa, "r", encoding='utf_8') as arpa:
|
||||
for line in arpa:
|
||||
# verify if the line corresponds to unigram
|
||||
if not re.match(r"[-]*[0-9\.]+\t\S+\t*[-]*[0-9\.]*$", line):
|
||||
continue
|
||||
word = line.split("\t")[1]
|
||||
word = word.strip().lower() if args.lower else word.strip()
|
||||
if word == "<UNK>" or word == "<unk>" or word == "<s>" or word == "</s>":
|
||||
continue
|
||||
|
||||
if tokenizer is None:
|
||||
f.write("{w}\t{s}\n".format(w=word, s=" ".join(word)))
|
||||
else:
|
||||
w_ids = tokenizer.text_to_ids(word)
|
||||
if tokenizer.unk_id not in w_ids:
|
||||
f.write("{w}\t{s}\n".format(w=word, s=" ".join(tokenizer.text_to_tokens(word))))
|
||||
@@ -0,0 +1,406 @@
|
||||
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""
|
||||
# This script would evaluate an N-gram language model trained with KenLM library (https://github.com/kpu/kenlm) in
|
||||
# fusion with beam search decoders on top of a trained ASR model with CTC decoder. To evaluate a model with
|
||||
# Transducer (RNN-T) decoder use another script 'scripts/asr_language_modeling/ngram_lm/eval_beamsearch_ngram_transducer.py'.
|
||||
# NeMo's beam search decoders are capable of using the KenLM's N-gram models
|
||||
# to find the best candidates. This script supports both character level and BPE level
|
||||
# encodings and models which is detected automatically from the type of the model.
|
||||
# You may train the LM model with 'scripts/asr_language_modeling/ngram_lm/train_kenlm.py'.
|
||||
|
||||
# Config Help
|
||||
|
||||
To discover all arguments of the script, please run :
|
||||
python eval_beamsearch_ngram_ctc.py --help
|
||||
python eval_beamsearch_ngram_ctc.py --cfg job
|
||||
|
||||
# USAGE
|
||||
|
||||
python eval_beamsearch_ngram_ctc.py nemo_model_file=<path to the .nemo file of the model> \
|
||||
input_manifest=<path to the evaluation JSON manifest file> \
|
||||
kenlm_model_file=<path to the binary KenLM model> \
|
||||
beam_width=[<list of the beam widths, separated with commas>] \
|
||||
beam_alpha=[<list of the beam alphas, separated with commas>] \
|
||||
beam_beta=[<list of the beam betas, separated with commas>] \
|
||||
preds_output_folder=<optional folder to store the predictions> \
|
||||
probs_cache_file=null \
|
||||
decoding_mode=beamsearch_ngram
|
||||
...
|
||||
|
||||
|
||||
# Grid Search for Hyper parameters
|
||||
|
||||
For grid search, you can provide a list of arguments as follows -
|
||||
|
||||
beam_width=[4,8,16,....] \
|
||||
beam_alpha=[-2.0,-1.0,...,1.0,2.0] \
|
||||
beam_beta=[-1.0,-0.5,0.0,...,1.0] \
|
||||
|
||||
# You may find more info on how to use this script at:
|
||||
# https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/asr_language_modeling.html
|
||||
|
||||
"""
|
||||
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import msgpack
|
||||
import numpy as np
|
||||
import torch
|
||||
from kaldialign import edit_distance
|
||||
from omegaconf import MISSING, OmegaConf
|
||||
from sklearn.model_selection import ParameterGrid
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.collections.asr.models import EncDecHybridRNNTCTCModel
|
||||
from nemo.collections.asr.parts.submodules import ctc_beam_decoding
|
||||
from nemo.collections.asr.parts.utils.transcribe_utils import PunctuationCapitalization, TextProcessingConfig
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging
|
||||
|
||||
# fmt: off
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvalBeamSearchNGramConfig:
|
||||
"""
|
||||
Evaluate an ASR model with beam search decoding and n-gram KenLM language model.
|
||||
"""
|
||||
# # The path of the '.nemo' file of the ASR model or the name of a pretrained model (ngc / huggingface)
|
||||
nemo_model_file: str = MISSING
|
||||
|
||||
# File paths
|
||||
input_manifest: str = MISSING # The manifest file of the evaluation set
|
||||
kenlm_model_file: Optional[str] = None # The path of the KenLM binary model file
|
||||
preds_output_folder: Optional[str] = None # The optional folder where the predictions are stored
|
||||
hyps_cache_file: Optional[str] = None # The cache file for storing the logprobs of the model
|
||||
|
||||
# Parameters for inference
|
||||
batch_size: int = 16 # The batch size
|
||||
device: str = "cuda" # The device to load the model onto to calculate log probabilities
|
||||
use_amp: bool = False # Whether to use AMP if available to calculate log probabilities
|
||||
|
||||
# Beam Search hyperparameters
|
||||
|
||||
# The decoding scheme to be used for evaluation.
|
||||
# Can be one of ["greedy", "beamsearch", "beamsearch_ngram"]
|
||||
decoding_mode: str = "beamsearch_ngram"
|
||||
|
||||
beam_width: List[int] = field(default_factory=lambda: [128]) # The width or list of the widths for the beam search decoding
|
||||
beam_alpha: List[float] = field(default_factory=lambda: [1.0]) # The alpha parameter or list of the alphas for the beam search decoding
|
||||
beam_beta: List[float] = field(default_factory=lambda: [0.0]) # The beta parameter or list of the betas for the beam search decoding
|
||||
|
||||
decoding_strategy: str = "beam"
|
||||
decoding: ctc_beam_decoding.BeamCTCInferConfig = field(default_factory=lambda: ctc_beam_decoding.BeamCTCInferConfig(beam_size=128))
|
||||
|
||||
text_processing: Optional[TextProcessingConfig] = field(default_factory=lambda: TextProcessingConfig(
|
||||
punctuation_marks = ".,?",
|
||||
separate_punctuation = False,
|
||||
do_lowercase = False,
|
||||
rm_punctuation = False,
|
||||
))
|
||||
# fmt: on
|
||||
|
||||
|
||||
def apply_text_processing(
|
||||
punctuation_capitalization: PunctuationCapitalization, cfg: EvalBeamSearchNGramConfig, text: List[str] | str
|
||||
) -> List[str] | str:
|
||||
is_list = isinstance(text, list)
|
||||
text_arr = text if is_list else [text]
|
||||
if cfg.text_processing.do_lowercase:
|
||||
text_arr = punctuation_capitalization.do_lowercase(text_arr)
|
||||
if cfg.text_processing.rm_punctuation:
|
||||
text_arr = punctuation_capitalization.rm_punctuation(text_arr)
|
||||
if cfg.text_processing.separate_punctuation:
|
||||
text_arr = punctuation_capitalization.separate_punctuation(text_arr)
|
||||
|
||||
return text_arr if is_list else text_arr[0]
|
||||
|
||||
|
||||
def beam_search_eval(
|
||||
audio_filepaths,
|
||||
model: nemo_asr.models.ASRModel,
|
||||
cfg: EvalBeamSearchNGramConfig,
|
||||
target_transcripts: List[str],
|
||||
preds_output_file: str = None,
|
||||
lm_path: str = None,
|
||||
beam_alpha: float = 1.0,
|
||||
beam_beta: float = 0.0,
|
||||
beam_width: int = 128,
|
||||
punctuation_capitalization: PunctuationCapitalization = None,
|
||||
):
|
||||
level = logging.getEffectiveLevel()
|
||||
logging.setLevel(logging.CRITICAL)
|
||||
# Reset config
|
||||
if isinstance(model, EncDecHybridRNNTCTCModel):
|
||||
model.change_decoding_strategy(decoding_cfg=None, decoder_type="ctc")
|
||||
else:
|
||||
model.change_decoding_strategy(None)
|
||||
|
||||
# Override the beam search config with current search candidate configuration
|
||||
cfg.decoding.beam_size = beam_width
|
||||
cfg.decoding.ngram_lm_alpha = beam_alpha
|
||||
cfg.decoding.beam_beta = beam_beta
|
||||
cfg.decoding.return_best_hypothesis = False
|
||||
cfg.decoding.ngram_lm_model = cfg.kenlm_model_file
|
||||
|
||||
# Update model's decoding strategy config
|
||||
model.cfg.decoding.strategy = cfg.decoding_strategy
|
||||
model.cfg.decoding.beam = cfg.decoding
|
||||
|
||||
# Update model's decoding strategy
|
||||
if isinstance(model, EncDecHybridRNNTCTCModel):
|
||||
model.change_decoding_strategy(model.cfg.decoding, decoder_type='ctc')
|
||||
else:
|
||||
model.change_decoding_strategy(model.cfg.decoding)
|
||||
logging.setLevel(level)
|
||||
|
||||
all_hyps = model.transcribe(audio_filepaths, cfg.batch_size)
|
||||
|
||||
wer_dist_first = cer_dist_first = 0
|
||||
wer_dist_best = cer_dist_best = 0
|
||||
words_count = 0
|
||||
chars_count = 0
|
||||
if preds_output_file:
|
||||
out_file = open(preds_output_file, 'w', encoding='utf_8', newline='\n')
|
||||
|
||||
for batch_idx, nbest_hyp in enumerate(all_hyps):
|
||||
target = target_transcripts[batch_idx]
|
||||
target_split_w = target.split()
|
||||
target_split_c = list(target)
|
||||
words_count += len(target_split_w)
|
||||
chars_count += len(target_split_c)
|
||||
wer_dist_min = cer_dist_min = float("inf")
|
||||
for candidate_idx, candidate in enumerate(nbest_hyp):
|
||||
pred_text = apply_text_processing(punctuation_capitalization, cfg, candidate.text)
|
||||
|
||||
pred_split_w = pred_text.split()
|
||||
wer_dist = edit_distance(target_split_w, pred_split_w)['total']
|
||||
pred_split_c = list(pred_text)
|
||||
cer_dist = edit_distance(target_split_c, pred_split_c)['total']
|
||||
|
||||
wer_dist_min = min(wer_dist_min, wer_dist)
|
||||
cer_dist_min = min(cer_dist_min, cer_dist)
|
||||
|
||||
if candidate_idx == 0:
|
||||
# first candidate
|
||||
wer_dist_first += wer_dist
|
||||
cer_dist_first += cer_dist
|
||||
|
||||
score = candidate.score
|
||||
if preds_output_file:
|
||||
out_file.write('{}\t{}\n'.format(pred_text, score))
|
||||
wer_dist_best += wer_dist_min
|
||||
cer_dist_best += cer_dist_min
|
||||
|
||||
if preds_output_file:
|
||||
out_file.close()
|
||||
logging.info(f"Stored the predictions of beam search decoding at '{preds_output_file}'.")
|
||||
|
||||
if lm_path:
|
||||
logging.info(
|
||||
'WER/CER with beam search decoding and N-gram model = {:.2%}/{:.2%}'.format(
|
||||
wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
)
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
'WER/CER with beam search decoding = {:.2%}/{:.2%}'.format(
|
||||
wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
)
|
||||
)
|
||||
logging.info(
|
||||
'Oracle WER/CER in candidates with perfect LM= {:.2%}/{:.2%}'.format(
|
||||
wer_dist_best / words_count, cer_dist_best / chars_count
|
||||
)
|
||||
)
|
||||
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
return wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
|
||||
|
||||
@hydra_runner(config_path=None, config_name='EvalBeamSearchNGramConfig', schema=EvalBeamSearchNGramConfig)
|
||||
def main(cfg: EvalBeamSearchNGramConfig):
|
||||
if is_dataclass(cfg):
|
||||
cfg = OmegaConf.structured(cfg) # type: EvalBeamSearchNGramConfig
|
||||
|
||||
valid_decoding_modes = ["greedy", "beamsearch", "beamsearch_ngram"]
|
||||
if cfg.decoding_mode not in valid_decoding_modes:
|
||||
raise ValueError(
|
||||
f"Given decoding_mode={cfg.decoding_mode} is invalid. Available options are :\n" f"{valid_decoding_modes}"
|
||||
)
|
||||
|
||||
if cfg.nemo_model_file.endswith('.nemo'):
|
||||
asr_model = nemo_asr.models.ASRModel.restore_from(cfg.nemo_model_file, map_location=torch.device(cfg.device))
|
||||
else:
|
||||
logging.warning(
|
||||
"nemo_model_file does not end with .nemo, therefore trying to load a pretrained model with this name."
|
||||
)
|
||||
asr_model = nemo_asr.models.ASRModel.from_pretrained(
|
||||
cfg.nemo_model_file, map_location=torch.device(cfg.device)
|
||||
)
|
||||
|
||||
target_transcripts = []
|
||||
manifest_dir = Path(cfg.input_manifest).parent
|
||||
with open(cfg.input_manifest, 'r', encoding='utf_8') as manifest_file:
|
||||
audio_file_paths = []
|
||||
for line in tqdm(manifest_file, desc=f"Reading Manifest {cfg.input_manifest} ...", ncols=120):
|
||||
data = json.loads(line)
|
||||
audio_file = Path(data['audio_filepath'])
|
||||
if not audio_file.is_file() and not audio_file.is_absolute():
|
||||
audio_file = manifest_dir / audio_file
|
||||
target_transcripts.append(data['text'])
|
||||
audio_file_paths.append(str(audio_file.absolute()))
|
||||
|
||||
punctuation_capitalization = PunctuationCapitalization(cfg.text_processing.punctuation_marks)
|
||||
target_transcripts = apply_text_processing(punctuation_capitalization, cfg, target_transcripts)
|
||||
|
||||
if cfg.hyps_cache_file and os.path.exists(cfg.hyps_cache_file):
|
||||
logging.info(f"Found a cached file of hypotheses at '{cfg.hyps_cache_file}'.")
|
||||
logging.info(f"Loading the cached file of hypotheses from '{cfg.hyps_cache_file}' ...")
|
||||
with open(cfg.hyps_cache_file, 'rb') as probs_file:
|
||||
all_hyps = msgpack.load(probs_file)
|
||||
|
||||
if len(all_hyps) != len(audio_file_paths):
|
||||
raise ValueError(
|
||||
f"The number of samples in the hypotheses file '{cfg.hyps_cache_file}' does not "
|
||||
f"match the manifest file. You may need to delete the hypotheses cached file."
|
||||
)
|
||||
else:
|
||||
|
||||
with torch.amp.autocast(asr_model.device.type, enabled=cfg.use_amp):
|
||||
with torch.no_grad():
|
||||
if isinstance(asr_model, EncDecHybridRNNTCTCModel):
|
||||
asr_model.cur_decoder = 'ctc'
|
||||
all_hyps = asr_model.transcribe(audio_file_paths, batch_size=cfg.batch_size)
|
||||
|
||||
if cfg.hyps_cache_file:
|
||||
os.makedirs(os.path.split(cfg.hyps_cache_file)[0], exist_ok=True)
|
||||
logging.info(f"Writing cached files of hypotheses at '{cfg.hyps_cache_file}'...")
|
||||
with open(cfg.hyps_cache_file, 'wb') as f_dump:
|
||||
msgpack.dump(all_hyps, f_dump)
|
||||
|
||||
wer_dist_greedy = 0
|
||||
cer_dist_greedy = 0
|
||||
words_count = 0
|
||||
chars_count = 0
|
||||
for batch_idx, hyp in enumerate(all_hyps):
|
||||
pred_text = apply_text_processing(punctuation_capitalization, cfg, hyp.text)
|
||||
|
||||
pred_split_w = pred_text.split()
|
||||
target_split_w = target_transcripts[batch_idx].split()
|
||||
pred_split_c = list(pred_text)
|
||||
target_split_c = list(target_transcripts[batch_idx])
|
||||
|
||||
wer_dist = edit_distance(target_split_w, pred_split_w)['total']
|
||||
cer_dist = edit_distance(target_split_c, pred_split_c)['total']
|
||||
|
||||
wer_dist_greedy += wer_dist
|
||||
cer_dist_greedy += cer_dist
|
||||
words_count += len(target_split_w)
|
||||
chars_count += len(target_split_c)
|
||||
|
||||
logging.info('Greedy WER/CER = {:.2%}/{:.2%}'.format(wer_dist_greedy / words_count, cer_dist_greedy / chars_count))
|
||||
|
||||
asr_model = asr_model.to('cpu')
|
||||
|
||||
if cfg.decoding_mode == "beamsearch_ngram":
|
||||
if not os.path.exists(cfg.kenlm_model_file):
|
||||
raise FileNotFoundError(f"Could not find the KenLM model file '{cfg.kenlm_model_file}'.")
|
||||
lm_path = cfg.kenlm_model_file
|
||||
else:
|
||||
lm_path = None
|
||||
|
||||
# 'greedy' decoding_mode would skip the beam search decoding
|
||||
if cfg.decoding_mode in ["beamsearch_ngram", "beamsearch"]:
|
||||
if cfg.beam_width is None or cfg.beam_alpha is None or cfg.beam_beta is None:
|
||||
raise ValueError("beam_width, beam_alpha and beam_beta are needed to perform beam search decoding.")
|
||||
params = {'beam_width': cfg.beam_width, 'beam_alpha': cfg.beam_alpha, 'beam_beta': cfg.beam_beta}
|
||||
hp_grid = ParameterGrid(params)
|
||||
hp_grid = list(hp_grid)
|
||||
|
||||
best_wer_beam_size, best_cer_beam_size = None, None
|
||||
best_wer_alpha, best_cer_alpha = None, None
|
||||
best_wer_beta, best_cer_beta = None, None
|
||||
best_wer, best_cer = float("inf"), float("inf")
|
||||
|
||||
logging.info(f"==============================Starting the beam search decoding===============================")
|
||||
logging.info(f"Grid search size: {len(hp_grid)}")
|
||||
logging.info(f"It may take some time...")
|
||||
logging.info(f"==============================================================================================")
|
||||
|
||||
if cfg.preds_output_folder and not os.path.exists(cfg.preds_output_folder):
|
||||
os.mkdir(cfg.preds_output_folder)
|
||||
for hp in hp_grid:
|
||||
if cfg.preds_output_folder:
|
||||
preds_output_file = os.path.join(
|
||||
cfg.preds_output_folder,
|
||||
f"preds_out_width{hp['beam_width']}_alpha{hp['beam_alpha']}_beta{hp['beam_beta']}.tsv",
|
||||
)
|
||||
else:
|
||||
preds_output_file = None
|
||||
|
||||
candidate_wer, candidate_cer = beam_search_eval(
|
||||
audio_file_paths,
|
||||
asr_model,
|
||||
cfg,
|
||||
target_transcripts=target_transcripts,
|
||||
preds_output_file=preds_output_file,
|
||||
lm_path=lm_path,
|
||||
beam_width=hp["beam_width"],
|
||||
beam_alpha=hp["beam_alpha"],
|
||||
beam_beta=hp["beam_beta"],
|
||||
punctuation_capitalization=punctuation_capitalization,
|
||||
)
|
||||
|
||||
if candidate_cer < best_cer:
|
||||
best_cer_beam_size, best_cer_alpha, best_cer_beta, best_cer = (
|
||||
hp["beam_width"],
|
||||
hp["beam_alpha"],
|
||||
hp["beam_beta"],
|
||||
candidate_cer,
|
||||
)
|
||||
|
||||
if candidate_wer < best_wer:
|
||||
best_wer_beam_size, best_wer_alpha, best_wer_beta, best_wer = (
|
||||
hp["beam_width"],
|
||||
hp["beam_alpha"],
|
||||
hp["beam_beta"],
|
||||
candidate_wer,
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f'Best WER Candidate = {best_wer:.2%} :: Beam size = {best_wer_beam_size}, '
|
||||
f'Beam alpha = {best_wer_alpha}, Beam beta = {best_wer_beta}'
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f'Best CER Candidate = {best_cer:.2%} :: Beam size = {best_cer_beam_size}, '
|
||||
f'Beam alpha = {best_cer_alpha}, Beam beta = {best_cer_beta}'
|
||||
)
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,444 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""
|
||||
# This script would evaluate an N-gram language model trained with KenLM library (https://github.com/kpu/kenlm) in
|
||||
# fusion with beam search decoders on top of a trained ASR Transducer model. NeMo's beam search decoders are capable of using the
|
||||
# KenLM's N-gram models to find the best candidates. This script supports both character level and BPE level
|
||||
# encodings and models which is detected automatically from the type of the model.
|
||||
# You may train the LM model with 'scripts/ngram_lm/train_kenlm.py'.
|
||||
|
||||
# Config Help
|
||||
|
||||
To discover all arguments of the script, please run :
|
||||
python eval_beamsearch_ngram.py --help
|
||||
python eval_beamsearch_ngram.py --cfg job
|
||||
|
||||
# USAGE
|
||||
|
||||
python eval_beamsearch_ngram_transducer.py nemo_model_file=<path to the .nemo file of the model> \
|
||||
input_manifest=<path to the evaluation JSON manifest file \
|
||||
kenlm_model_file=<path to the binary KenLM model> \
|
||||
beam_width=[<list of the beam widths, separated with commas>] \
|
||||
beam_alpha=[<list of the beam alphas, separated with commas>] \
|
||||
preds_output_folder=<optional folder to store the predictions> \
|
||||
probs_cache_file=null \
|
||||
decoding_strategy=<greedy_batch or maes decoding>
|
||||
maes_prefix_alpha=[<list of the maes prefix alphas, separated with commas>] \
|
||||
maes_expansion_gamma=[<list of the maes expansion gammas, separated with commas>] \
|
||||
hat_subtract_ilm=<in case of HAT model: subtract internal LM or not> \
|
||||
hat_ilm_weight=[<in case of HAT model: list of the HAT internal LM weights, separated with commas>] \
|
||||
...
|
||||
|
||||
|
||||
# Grid Search for Hyper parameters
|
||||
|
||||
For grid search, you can provide a list of arguments as follows -
|
||||
|
||||
beam_width=[4,8,16,....] \
|
||||
beam_alpha=[-2.0,-1.0,...,1.0,2.0] \
|
||||
|
||||
# You may find more info on how to use this script at:
|
||||
# https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/asr_language_modeling.html
|
||||
|
||||
"""
|
||||
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import msgpack
|
||||
import numpy as np
|
||||
import torch
|
||||
from kaldialign import edit_distance
|
||||
from omegaconf import MISSING, OmegaConf
|
||||
from sklearn.model_selection import ParameterGrid
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.collections.asr.parts.submodules import rnnt_beam_decoding
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging
|
||||
|
||||
# fmt: off
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvalBeamSearchNGramConfig:
|
||||
"""
|
||||
Evaluate an ASR model with beam search decoding and n-gram KenLM language model.
|
||||
"""
|
||||
# # The path of the '.nemo' file of the ASR model or the name of a pretrained model (ngc / huggingface)
|
||||
nemo_model_file: str = MISSING
|
||||
|
||||
# File paths
|
||||
input_manifest: str = MISSING # The manifest file of the evaluation set
|
||||
kenlm_model_file: Optional[str] = None # The path of the KenLM binary model file
|
||||
preds_output_folder: Optional[str] = None # The optional folder where the predictions are stored
|
||||
probs_cache_file: Optional[str] = None # The cache file for storing the logprobs of the model
|
||||
|
||||
# Parameters for inference
|
||||
acoustic_batch_size: int = 128 # The batch size to calculate log probabilities
|
||||
beam_batch_size: int = 128 # The batch size to be used for beam search decoding
|
||||
device: str = "cuda" # The device to load the model onto to calculate log probabilities
|
||||
use_amp: bool = False # Whether to use AMP if available to calculate log probabilities
|
||||
num_workers: int = 1 # Number of workers for DataLoader
|
||||
|
||||
# The decoding scheme to be used for evaluation
|
||||
decoding_strategy: str = "greedy_batch" # ["greedy_batch", "beam", "tsd", "alsd", "maes"]
|
||||
|
||||
# Beam Search hyperparameters
|
||||
beam_width: List[int] = field(default_factory=lambda: [8]) # The width or list of the widths for the beam search decoding
|
||||
beam_alpha: List[float] = field(default_factory=lambda: [0.2]) # The alpha parameter or list of the alphas for the beam search decoding
|
||||
|
||||
maes_prefix_alpha: List[int] = field(default_factory=lambda: [2]) # The maes_prefix_alpha or list of the maes_prefix_alpha for the maes decoding
|
||||
maes_expansion_gamma: List[float] = field(default_factory=lambda: [2.3]) # The maes_expansion_gamma or list of the maes_expansion_gamma for the maes decoding
|
||||
|
||||
# HAT related parameters (only for internal lm subtraction)
|
||||
hat_subtract_ilm: bool = False
|
||||
hat_ilm_weight: List[float] = field(default_factory=lambda: [0.0])
|
||||
|
||||
decoding: rnnt_beam_decoding.BeamRNNTInferConfig = field(default_factory=lambda: rnnt_beam_decoding.BeamRNNTInferConfig(beam_size=128))
|
||||
|
||||
|
||||
# fmt: on
|
||||
|
||||
|
||||
def decoding_step(
|
||||
model: nemo_asr.models.ASRModel,
|
||||
cfg: EvalBeamSearchNGramConfig,
|
||||
all_probs: List[torch.Tensor],
|
||||
target_transcripts: List[str],
|
||||
preds_output_file: str = None,
|
||||
beam_batch_size: int = 128,
|
||||
progress_bar: bool = True,
|
||||
):
|
||||
level = logging.getEffectiveLevel()
|
||||
logging.setLevel(logging.CRITICAL)
|
||||
# Reset config
|
||||
model.change_decoding_strategy(None)
|
||||
|
||||
cfg.decoding.hat_ilm_weight = cfg.decoding.hat_ilm_weight * cfg.hat_subtract_ilm
|
||||
# Override the beam search config with current search candidate configuration
|
||||
cfg.decoding.return_best_hypothesis = False
|
||||
cfg.decoding.ngram_lm_model = cfg.kenlm_model_file
|
||||
cfg.decoding.hat_subtract_ilm = cfg.hat_subtract_ilm
|
||||
|
||||
# Update model's decoding strategy config
|
||||
model.cfg.decoding.strategy = cfg.decoding_strategy
|
||||
model.cfg.decoding.beam = cfg.decoding
|
||||
|
||||
# Update model's decoding strategy
|
||||
model.change_decoding_strategy(model.cfg.decoding)
|
||||
logging.setLevel(level)
|
||||
|
||||
wer_dist_first = cer_dist_first = 0
|
||||
wer_dist_best = cer_dist_best = 0
|
||||
words_count = 0
|
||||
chars_count = 0
|
||||
sample_idx = 0
|
||||
if preds_output_file:
|
||||
out_file = open(preds_output_file, 'w', encoding='utf_8', newline='\n')
|
||||
|
||||
if progress_bar:
|
||||
if cfg.decoding_strategy == "greedy_batch":
|
||||
description = "Greedy_batch decoding.."
|
||||
else:
|
||||
description = f"{cfg.decoding_strategy} decoding with bw={cfg.decoding.beam_size}, ba={cfg.decoding.ngram_lm_alpha}, ma={cfg.decoding.maes_prefix_alpha}, mg={cfg.decoding.maes_expansion_gamma}, hat_ilmw={cfg.decoding.hat_ilm_weight}"
|
||||
it = tqdm(range(int(np.ceil(len(all_probs) / beam_batch_size))), desc=description, ncols=120)
|
||||
else:
|
||||
it = range(int(np.ceil(len(all_probs) / beam_batch_size)))
|
||||
for batch_idx in it:
|
||||
# disabling type checking
|
||||
probs_batch = all_probs[batch_idx * beam_batch_size : (batch_idx + 1) * beam_batch_size]
|
||||
probs_lens = torch.tensor([prob.shape[-1] for prob in probs_batch])
|
||||
with torch.no_grad():
|
||||
packed_batch = torch.zeros(len(probs_batch), probs_batch[0].shape[0], max(probs_lens), device='cpu')
|
||||
|
||||
for prob_index in range(len(probs_batch)):
|
||||
packed_batch[prob_index, :, : probs_lens[prob_index]] = torch.tensor(
|
||||
probs_batch[prob_index].unsqueeze(0), device=packed_batch.device, dtype=packed_batch.dtype
|
||||
)
|
||||
beams_batch = model.decoding.rnnt_decoder_predictions_tensor(
|
||||
packed_batch,
|
||||
probs_lens,
|
||||
return_hypotheses=True,
|
||||
)
|
||||
best_hyp_batch = [dec_hyp[0] for dec_hyp in beams_batch]
|
||||
if cfg.decoding_strategy == "greedy_batch":
|
||||
beams_batch = [[x] for x in best_hyp_batch]
|
||||
|
||||
for beams_idx, beams in enumerate(beams_batch):
|
||||
target = target_transcripts[sample_idx + beams_idx]
|
||||
target_split_w = target.split()
|
||||
target_split_c = list(target)
|
||||
words_count += len(target_split_w)
|
||||
chars_count += len(target_split_c)
|
||||
wer_dist_min = cer_dist_min = 10000
|
||||
for candidate_idx, candidate in enumerate(beams): # type: (int, rnnt_beam_decoding.rnnt_utils.Hypothesis)
|
||||
pred_text = candidate.text
|
||||
pred_split_w = pred_text.split()
|
||||
wer_dist = edit_distance(target_split_w, pred_split_w)['total']
|
||||
pred_split_c = list(pred_text)
|
||||
cer_dist = edit_distance(target_split_c, pred_split_c)['total']
|
||||
|
||||
wer_dist_min = min(wer_dist_min, wer_dist)
|
||||
cer_dist_min = min(cer_dist_min, cer_dist)
|
||||
|
||||
if candidate_idx == 0:
|
||||
# first candidate
|
||||
wer_dist_first += wer_dist
|
||||
cer_dist_first += cer_dist
|
||||
|
||||
score = candidate.score
|
||||
if preds_output_file:
|
||||
out_file.write('{}\t{}\n'.format(pred_text, score))
|
||||
wer_dist_best += wer_dist_min
|
||||
cer_dist_best += cer_dist_min
|
||||
sample_idx += len(probs_batch)
|
||||
|
||||
if cfg.decoding_strategy == "greedy_batch":
|
||||
return wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
|
||||
if preds_output_file:
|
||||
out_file.close()
|
||||
logging.info(f"Stored the predictions of {cfg.decoding_strategy} decoding at '{preds_output_file}'.")
|
||||
|
||||
if cfg.decoding.ngram_lm_model:
|
||||
logging.info(
|
||||
f"WER/CER with {cfg.decoding_strategy} decoding and N-gram model = {wer_dist_first / words_count:.2%}/{cer_dist_first / chars_count:.2%}"
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
f"WER/CER with {cfg.decoding_strategy} decoding = {wer_dist_first / words_count:.2%}/{cer_dist_first / chars_count:.2%}"
|
||||
)
|
||||
logging.info(
|
||||
f"Oracle WER/CER in candidates with perfect LM= {wer_dist_best / words_count:.2%}/{cer_dist_best / chars_count:.2%}"
|
||||
)
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
return wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
|
||||
|
||||
@hydra_runner(config_path=None, config_name='EvalBeamSearchNGramConfig', schema=EvalBeamSearchNGramConfig)
|
||||
def main(cfg: EvalBeamSearchNGramConfig):
|
||||
if is_dataclass(cfg):
|
||||
cfg = OmegaConf.structured(cfg) # type: EvalBeamSearchNGramConfig
|
||||
|
||||
valid_decoding_strategis = ["greedy_batch", "beam", "tsd", "alsd", "maes"]
|
||||
if cfg.decoding_strategy not in valid_decoding_strategis:
|
||||
raise ValueError(
|
||||
f"Given decoding_strategy={cfg.decoding_strategy} is invalid. Available options are :\n"
|
||||
f"{valid_decoding_strategis}"
|
||||
)
|
||||
|
||||
if cfg.nemo_model_file.endswith('.nemo'):
|
||||
asr_model = nemo_asr.models.ASRModel.restore_from(cfg.nemo_model_file, map_location=torch.device(cfg.device))
|
||||
else:
|
||||
logging.warning(
|
||||
"nemo_model_file does not end with .nemo, therefore trying to load a pretrained model with this name."
|
||||
)
|
||||
asr_model = nemo_asr.models.ASRModel.from_pretrained(
|
||||
cfg.nemo_model_file, map_location=torch.device(cfg.device)
|
||||
)
|
||||
|
||||
if cfg.kenlm_model_file:
|
||||
if not os.path.exists(cfg.kenlm_model_file):
|
||||
raise FileNotFoundError(f"Could not find the KenLM model file '{cfg.kenlm_model_file}'.")
|
||||
if cfg.decoding_strategy != "maes":
|
||||
raise ValueError(f"Decoding with kenlm model is supported only for maes decoding algorithm.")
|
||||
lm_path = cfg.kenlm_model_file
|
||||
else:
|
||||
lm_path = None
|
||||
cfg.beam_alpha = [0.0]
|
||||
if cfg.hat_subtract_ilm:
|
||||
assert lm_path, "kenlm must be set for hat internal lm subtraction"
|
||||
|
||||
if cfg.decoding_strategy != "maes":
|
||||
cfg.maes_prefix_alpha, cfg.maes_expansion_gamma, cfg.hat_ilm_weight = [0], [0], [0]
|
||||
|
||||
target_transcripts = []
|
||||
manifest_dir = Path(cfg.input_manifest).parent
|
||||
with open(cfg.input_manifest, 'r', encoding='utf_8') as manifest_file:
|
||||
audio_file_paths = []
|
||||
for line in tqdm(manifest_file, desc=f"Reading Manifest {cfg.input_manifest} ...", ncols=120):
|
||||
data = json.loads(line)
|
||||
audio_file = Path(data['audio_filepath'])
|
||||
if not audio_file.is_file() and not audio_file.is_absolute():
|
||||
audio_file = manifest_dir / audio_file
|
||||
target_transcripts.append(data['text'])
|
||||
audio_file_paths.append(str(audio_file.absolute()))
|
||||
|
||||
if cfg.probs_cache_file and os.path.exists(cfg.probs_cache_file):
|
||||
logging.info(f"Found a cached file of probabilities at '{cfg.probs_cache_file}'.")
|
||||
logging.info(f"Loading the cached file of probabilities from '{cfg.probs_cache_file}' ...")
|
||||
with open(cfg.probs_cache_file, 'rb') as probs_file:
|
||||
all_probs = msgpack.load(probs_file)
|
||||
|
||||
if len(all_probs) != len(audio_file_paths):
|
||||
raise ValueError(
|
||||
f"The number of samples in the probabilities file '{cfg.probs_cache_file}' does not "
|
||||
f"match the manifest file. You may need to delete the probabilities cached file."
|
||||
)
|
||||
else:
|
||||
|
||||
# manual calculation of encoder_embeddings
|
||||
with torch.amp.autocast(asr_model.device.type, enabled=cfg.use_amp):
|
||||
with torch.no_grad():
|
||||
asr_model.eval()
|
||||
asr_model.encoder.freeze()
|
||||
device = next(asr_model.parameters()).device
|
||||
all_probs = []
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with open(os.path.join(tmpdir, 'manifest.json'), 'w', encoding='utf-8') as fp:
|
||||
for audio_file in audio_file_paths:
|
||||
entry = {'audio_filepath': audio_file, 'duration': 100000, 'text': ''}
|
||||
fp.write(json.dumps(entry) + '\n')
|
||||
config = {
|
||||
'paths2audio_files': audio_file_paths,
|
||||
'batch_size': cfg.acoustic_batch_size,
|
||||
'temp_dir': tmpdir,
|
||||
'num_workers': cfg.num_workers,
|
||||
'channel_selector': None,
|
||||
'augmentor': None,
|
||||
}
|
||||
temporary_datalayer = asr_model._setup_transcribe_dataloader(config)
|
||||
for test_batch in tqdm(temporary_datalayer, desc="Transcribing", disable=True):
|
||||
encoded, encoded_len = asr_model.forward(
|
||||
input_signal=test_batch[0].to(device), input_signal_length=test_batch[1].to(device)
|
||||
)
|
||||
# dump encoder embeddings per file
|
||||
for idx in range(encoded.shape[0]):
|
||||
encoded_no_pad = encoded[idx, :, : encoded_len[idx]]
|
||||
all_probs.append(encoded_no_pad)
|
||||
|
||||
if cfg.probs_cache_file:
|
||||
logging.info(f"Writing cached files of probabilities at '{cfg.probs_cache_file}'...")
|
||||
with open(cfg.probs_cache_file, 'wb') as f_dump:
|
||||
msgpack.dump(all_probs, f_dump)
|
||||
|
||||
if cfg.decoding_strategy == "greedy_batch":
|
||||
asr_model = asr_model.to('cpu')
|
||||
candidate_wer, candidate_cer = decoding_step(
|
||||
asr_model,
|
||||
cfg,
|
||||
all_probs=all_probs,
|
||||
target_transcripts=target_transcripts,
|
||||
beam_batch_size=cfg.beam_batch_size,
|
||||
progress_bar=True,
|
||||
)
|
||||
logging.info(f"Greedy batch WER/CER = {candidate_wer:.2%}/{candidate_cer:.2%}")
|
||||
|
||||
asr_model = asr_model.to('cpu')
|
||||
|
||||
# 'greedy_batch' decoding_strategy would skip the beam search decoding
|
||||
if cfg.decoding_strategy in ["beam", "tsd", "alsd", "maes"]:
|
||||
if cfg.beam_width is None or cfg.beam_alpha is None:
|
||||
raise ValueError("beam_width and beam_alpha are needed to perform beam search decoding.")
|
||||
params = {
|
||||
'beam_width': cfg.beam_width,
|
||||
'beam_alpha': cfg.beam_alpha,
|
||||
'maes_prefix_alpha': cfg.maes_prefix_alpha,
|
||||
'maes_expansion_gamma': cfg.maes_expansion_gamma,
|
||||
'hat_ilm_weight': cfg.hat_ilm_weight,
|
||||
}
|
||||
hp_grid = ParameterGrid(params)
|
||||
hp_grid = list(hp_grid)
|
||||
|
||||
best_wer_beam_size, best_cer_beam_size = None, None
|
||||
best_wer_alpha, best_cer_alpha = None, None
|
||||
best_wer, best_cer = 1e6, 1e6
|
||||
|
||||
logging.info(
|
||||
f"==============================Starting the {cfg.decoding_strategy} decoding==============================="
|
||||
)
|
||||
logging.info(f"Grid search size: {len(hp_grid)}")
|
||||
logging.info(f"It may take some time...")
|
||||
logging.info(f"==============================================================================================")
|
||||
|
||||
if cfg.preds_output_folder and not os.path.exists(cfg.preds_output_folder):
|
||||
os.mkdir(cfg.preds_output_folder)
|
||||
for hp in hp_grid:
|
||||
if cfg.preds_output_folder:
|
||||
results_file = f"preds_out_{cfg.decoding_strategy}_bw{hp['beam_width']}"
|
||||
if cfg.decoding_strategy == "maes":
|
||||
results_file = f"{results_file}_ma{hp['maes_prefix_alpha']}_mg{hp['maes_expansion_gamma']}"
|
||||
if cfg.kenlm_model_file:
|
||||
results_file = f"{results_file}_ba{hp['beam_alpha']}"
|
||||
if cfg.hat_subtract_ilm:
|
||||
results_file = f"{results_file}_hat_ilmw{hp['hat_ilm_weight']}"
|
||||
preds_output_file = os.path.join(cfg.preds_output_folder, f"{results_file}.tsv")
|
||||
else:
|
||||
preds_output_file = None
|
||||
|
||||
cfg.decoding.beam_size = hp["beam_width"]
|
||||
cfg.decoding.ngram_lm_alpha = hp["beam_alpha"]
|
||||
cfg.decoding.maes_prefix_alpha = hp["maes_prefix_alpha"]
|
||||
cfg.decoding.maes_expansion_gamma = hp["maes_expansion_gamma"]
|
||||
cfg.decoding.hat_ilm_weight = hp["hat_ilm_weight"]
|
||||
|
||||
candidate_wer, candidate_cer = decoding_step(
|
||||
asr_model,
|
||||
cfg,
|
||||
all_probs=all_probs,
|
||||
target_transcripts=target_transcripts,
|
||||
preds_output_file=preds_output_file,
|
||||
beam_batch_size=cfg.beam_batch_size,
|
||||
progress_bar=True,
|
||||
)
|
||||
|
||||
if candidate_cer < best_cer:
|
||||
best_cer_beam_size = hp["beam_width"]
|
||||
best_cer_alpha = hp["beam_alpha"]
|
||||
best_cer_ma = hp["maes_prefix_alpha"]
|
||||
best_cer_mg = hp["maes_expansion_gamma"]
|
||||
best_cer_hat_ilm_weight = hp["hat_ilm_weight"]
|
||||
best_cer = candidate_cer
|
||||
|
||||
if candidate_wer < best_wer:
|
||||
best_wer_beam_size = hp["beam_width"]
|
||||
best_wer_alpha = hp["beam_alpha"]
|
||||
best_wer_ma = hp["maes_prefix_alpha"]
|
||||
best_wer_ga = hp["maes_expansion_gamma"]
|
||||
best_wer_hat_ilm_weight = hp["hat_ilm_weight"]
|
||||
best_wer = candidate_wer
|
||||
|
||||
wer_hat_parameter = ""
|
||||
if cfg.hat_subtract_ilm:
|
||||
wer_hat_parameter = f"HAT ilm weight = {best_wer_hat_ilm_weight}, "
|
||||
logging.info(
|
||||
f'Best WER Candidate = {best_wer:.2%} :: Beam size = {best_wer_beam_size}, '
|
||||
f'Beam alpha = {best_wer_alpha}, {wer_hat_parameter}'
|
||||
f'maes_prefix_alpha = {best_wer_ma}, maes_expansion_gamma = {best_wer_ga} '
|
||||
)
|
||||
|
||||
cer_hat_parameter = ""
|
||||
if cfg.hat_subtract_ilm:
|
||||
cer_hat_parameter = f"HAT ilm weight = {best_cer_hat_ilm_weight}"
|
||||
logging.info(
|
||||
f'Best CER Candidate = {best_cer:.2%} :: Beam size = {best_cer_beam_size}, '
|
||||
f'Beam alpha = {best_cer_alpha}, {cer_hat_parameter} '
|
||||
f'maes_prefix_alpha = {best_cer_ma}, maes_expansion_gamma = {best_cer_mg}'
|
||||
)
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,424 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""
|
||||
# This script would evaluate an N-gram language model in ARPA format in
|
||||
# fusion with WFST decoders on top of a trained ASR model with CTC decoder.
|
||||
# NeMo's WFST decoders use WFST decoding graphs made from ARPA LMs
|
||||
# to find the best candidates. This script supports BPE level encodings only
|
||||
# and models which is detected automatically from the type of the model.
|
||||
# You may train the LM model with e.g. SRILM.
|
||||
|
||||
# Config Help
|
||||
|
||||
To discover all arguments of the script, please run :
|
||||
python eval_wfst_decoding_ctc.py --help
|
||||
python eval_wfst_decoding_ctc.py --cfg job
|
||||
|
||||
# USAGE
|
||||
|
||||
python eval_wfst_decoding_ctc.py nemo_model_file=<path to the .nemo file of the model> \
|
||||
input_manifest=<path to the evaluation JSON manifest file> \
|
||||
arpa_model_file=<path to the ARPA LM model> \
|
||||
decoding_wfst_file=<path to the decoding WFST file> \
|
||||
beam_width=[<list of the beam widths, separated with commas>] \
|
||||
lm_weight=[<list of the LM weight multipliers, separated with commas>] \
|
||||
decoding_mode=<decoding mode, affects output. Usually "nbest"> \
|
||||
decoding_search_type=<a.k.a. decoding backend. Usually "riva"> \
|
||||
open_vocabulary_decoding=<whether to use open vocabulary mode for WFST decoding> \
|
||||
preds_output_folder=<optional folder to store the predictions> \
|
||||
probs_cache_file=null
|
||||
...
|
||||
|
||||
|
||||
# Grid Search for Hyper parameters
|
||||
|
||||
For grid search, you can provide a list of arguments as follows -
|
||||
|
||||
beam_width=[5.0,10.0,15.0,20.0] \
|
||||
lm_weight=[0.1,0.5,0.6,0.7,0.8,0.9,1.0,1.1,1.2,1.3,1.4,1.5,2.0] \
|
||||
|
||||
"""
|
||||
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import msgpack
|
||||
import numpy as np
|
||||
import torch
|
||||
from kaldialign import edit_distance
|
||||
from omegaconf import MISSING, OmegaConf
|
||||
from sklearn.model_selection import ParameterGrid
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.collections.asr.models import EncDecHybridRNNTCTCModel
|
||||
from nemo.collections.asr.parts.submodules import ctc_beam_decoding
|
||||
from nemo.collections.asr.parts.utils.transcribe_utils import PunctuationCapitalization, TextProcessingConfig
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging
|
||||
|
||||
# fmt: off
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvalWFSTNGramConfig:
|
||||
"""
|
||||
Evaluate an ASR model with WFST decoding and n-gram ARPA language model.
|
||||
"""
|
||||
# # The path of the '.nemo' file of the ASR model or the name of a pretrained model (ngc / huggingface)
|
||||
nemo_model_file: str = MISSING
|
||||
|
||||
# File paths
|
||||
input_manifest: str = MISSING # The manifest file of the evaluation set
|
||||
arpa_model_file: Optional[str] = None # The path of the ARPA model file
|
||||
decoding_wfst_file: Optional[str] = None # The path of the decoding WFST file
|
||||
preds_output_folder: Optional[str] = None # The optional folder where the predictions are stored
|
||||
probs_cache_file: Optional[str] = None # The cache file for storing the logprobs of the model
|
||||
|
||||
# Parameters for inference
|
||||
acoustic_batch_size: int = 16 # The batch size to calculate log probabilities
|
||||
beam_batch_size: int = 512 # The batch size to be used for beam search decoding
|
||||
device: str = "cuda" # The device to load the model onto to calculate log probabilities and run WFST decoding
|
||||
use_amp: bool = False # Whether to use AMP if available to calculate log probabilities
|
||||
|
||||
# WFST decoding hyperparameters
|
||||
|
||||
beam_width: List[float] = field(default_factory=lambda: [10]) # The width or list of the beam widths for the WFST decoding
|
||||
lm_weight: List[float] = field(default_factory=lambda: [1.0]) # The language model weight parameter or list of parameters for the WFST decoding
|
||||
|
||||
open_vocabulary_decoding: bool = False # Whether to use open vocabulary mode for WFST decoding
|
||||
decoding_mode: str = "nbest"
|
||||
decoding_search_type: str = "riva"
|
||||
decoding: ctc_beam_decoding.WfstCTCInferConfig = field(
|
||||
default_factory=lambda: ctc_beam_decoding.WfstCTCInferConfig(beam_size=1)
|
||||
)
|
||||
|
||||
text_processing: Optional[TextProcessingConfig] = field(default_factory=lambda: TextProcessingConfig(
|
||||
punctuation_marks = ".,?",
|
||||
separate_punctuation = False,
|
||||
do_lowercase = False,
|
||||
rm_punctuation = False,
|
||||
))
|
||||
# fmt: on
|
||||
|
||||
|
||||
def beam_search_eval(
|
||||
model: nemo_asr.models.ASRModel,
|
||||
cfg: EvalWFSTNGramConfig,
|
||||
all_probs: List[torch.Tensor],
|
||||
target_transcripts: List[str],
|
||||
preds_output_file: str = None,
|
||||
lm_weight: float = 1.0,
|
||||
beam_width: float = 10.0,
|
||||
beam_batch_size: int = 512,
|
||||
progress_bar: bool = True,
|
||||
punctuation_capitalization: PunctuationCapitalization = None,
|
||||
):
|
||||
level = logging.getEffectiveLevel()
|
||||
logging.setLevel(logging.CRITICAL)
|
||||
# Reset config
|
||||
if isinstance(model, EncDecHybridRNNTCTCModel):
|
||||
model.change_decoding_strategy(decoding_cfg=None, decoder_type="ctc")
|
||||
else:
|
||||
model.change_decoding_strategy(None)
|
||||
|
||||
# Override the beam search config with current search candidate configuration
|
||||
cfg.decoding.beam_width = beam_width
|
||||
cfg.decoding.lm_weight = lm_weight
|
||||
cfg.decoding.open_vocabulary_decoding = cfg.open_vocabulary_decoding
|
||||
cfg.decoding.return_best_hypothesis = False
|
||||
cfg.decoding.arpa_lm_path = cfg.arpa_model_file
|
||||
cfg.decoding.wfst_lm_path = cfg.decoding_wfst_file
|
||||
cfg.decoding.device = cfg.device
|
||||
cfg.decoding.decoding_mode = cfg.decoding_mode
|
||||
cfg.decoding.search_type = cfg.decoding_search_type
|
||||
|
||||
# Update model's decoding strategy config
|
||||
model.cfg.decoding.strategy = "wfst"
|
||||
model.cfg.decoding.wfst = cfg.decoding
|
||||
|
||||
# Update model's decoding strategy
|
||||
if isinstance(model, EncDecHybridRNNTCTCModel):
|
||||
model.change_decoding_strategy(model.cfg.decoding, decoder_type='ctc')
|
||||
decoding = model.ctc_decoding
|
||||
else:
|
||||
model.change_decoding_strategy(model.cfg.decoding)
|
||||
decoding = model.decoding
|
||||
logging.setLevel(level)
|
||||
|
||||
wer_dist_first = cer_dist_first = 0
|
||||
wer_dist_best = cer_dist_best = 0
|
||||
words_count = 0
|
||||
chars_count = 0
|
||||
sample_idx = 0
|
||||
if preds_output_file:
|
||||
out_file = open(preds_output_file, 'w', encoding='utf_8', newline='\n')
|
||||
|
||||
if progress_bar:
|
||||
it = tqdm(
|
||||
range(int(np.ceil(len(all_probs) / beam_batch_size))),
|
||||
desc=f"Beam search decoding with width={beam_width}, lm_weight={lm_weight}",
|
||||
ncols=120,
|
||||
)
|
||||
else:
|
||||
it = range(int(np.ceil(len(all_probs) / beam_batch_size)))
|
||||
for batch_idx in it:
|
||||
# disabling type checking
|
||||
probs_batch = all_probs[batch_idx * beam_batch_size : (batch_idx + 1) * beam_batch_size]
|
||||
probs_lens = torch.tensor([prob.shape[0] for prob in probs_batch])
|
||||
with torch.no_grad():
|
||||
packed_batch = torch.zeros(len(probs_batch), max(probs_lens), probs_batch[0].shape[-1], device='cpu')
|
||||
|
||||
for prob_index in range(len(probs_batch)):
|
||||
packed_batch[prob_index, : probs_lens[prob_index], :] = probs_batch[prob_index].to(
|
||||
device=packed_batch.device, dtype=packed_batch.dtype
|
||||
)
|
||||
|
||||
_, beams_batch = decoding.ctc_decoder_predictions_tensor(
|
||||
packed_batch,
|
||||
decoder_lengths=probs_lens,
|
||||
return_hypotheses=True,
|
||||
)
|
||||
|
||||
for beams_idx, beams in enumerate(beams_batch):
|
||||
target = target_transcripts[sample_idx + beams_idx]
|
||||
target_split_w = target.split()
|
||||
target_split_c = list(target)
|
||||
words_count += len(target_split_w)
|
||||
chars_count += len(target_split_c)
|
||||
wer_dist_min = cer_dist_min = 10000
|
||||
for candidate_idx, candidate in enumerate(beams): # type: (int, ctc_beam_decoding.rnnt_utils.Hypothesis)
|
||||
pred_text = candidate.text
|
||||
if cfg.text_processing.do_lowercase:
|
||||
pred_text = punctuation_capitalization.do_lowercase([pred_text])[0]
|
||||
if cfg.text_processing.rm_punctuation:
|
||||
pred_text = punctuation_capitalization.rm_punctuation([pred_text])[0]
|
||||
if cfg.text_processing.separate_punctuation:
|
||||
pred_text = punctuation_capitalization.separate_punctuation([pred_text])[0]
|
||||
pred_split_w = pred_text.split()
|
||||
wer_dist = edit_distance(target_split_w, pred_split_w)['total']
|
||||
pred_split_c = list(pred_text)
|
||||
cer_dist = edit_distance(target_split_c, pred_split_c)['total']
|
||||
|
||||
wer_dist_min = min(wer_dist_min, wer_dist)
|
||||
cer_dist_min = min(cer_dist_min, cer_dist)
|
||||
|
||||
if candidate_idx == 0:
|
||||
# first candidate
|
||||
wer_dist_first += wer_dist
|
||||
cer_dist_first += cer_dist
|
||||
|
||||
score = candidate.score
|
||||
if preds_output_file:
|
||||
out_file.write(f'{pred_text}\t{score}\n')
|
||||
wer_dist_best += wer_dist_min
|
||||
cer_dist_best += cer_dist_min
|
||||
sample_idx += len(probs_batch)
|
||||
|
||||
if preds_output_file:
|
||||
out_file.close()
|
||||
logging.info(f"Stored the predictions of beam search decoding at '{preds_output_file}'.")
|
||||
|
||||
logging.info(
|
||||
'WER/CER with beam search decoding and N-gram model = {:.2%}/{:.2%}'.format(
|
||||
wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
)
|
||||
)
|
||||
logging.info(
|
||||
'Oracle WER/CER in candidates with perfect LM= {:.2%}/{:.2%}'.format(
|
||||
wer_dist_best / words_count, cer_dist_best / chars_count
|
||||
)
|
||||
)
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
return wer_dist_first / words_count, cer_dist_first / chars_count
|
||||
|
||||
|
||||
@hydra_runner(config_path=None, config_name='EvalWFSTNGramConfig', schema=EvalWFSTNGramConfig)
|
||||
def main(cfg: EvalWFSTNGramConfig):
|
||||
if is_dataclass(cfg):
|
||||
cfg = OmegaConf.structured(cfg) # type: EvalWFSTNGramConfig
|
||||
|
||||
if cfg.nemo_model_file.endswith('.nemo'):
|
||||
asr_model = nemo_asr.models.ASRModel.restore_from(cfg.nemo_model_file, map_location=torch.device(cfg.device))
|
||||
else:
|
||||
logging.warning(
|
||||
"nemo_model_file does not end with .nemo, therefore trying to load a pretrained model with this name."
|
||||
)
|
||||
asr_model = nemo_asr.models.ASRModel.from_pretrained(
|
||||
cfg.nemo_model_file, map_location=torch.device(cfg.device)
|
||||
)
|
||||
|
||||
target_transcripts = []
|
||||
manifest_dir = Path(cfg.input_manifest).parent
|
||||
with open(cfg.input_manifest, 'r', encoding='utf_8') as manifest_file:
|
||||
audio_file_paths = []
|
||||
for line in tqdm(manifest_file, desc=f"Reading Manifest {cfg.input_manifest} ...", ncols=120):
|
||||
data = json.loads(line)
|
||||
audio_file = Path(data['audio_filepath'])
|
||||
if not audio_file.is_file() and not audio_file.is_absolute():
|
||||
audio_file = manifest_dir / audio_file
|
||||
target_transcripts.append(data['text'])
|
||||
audio_file_paths.append(str(audio_file.absolute()))
|
||||
|
||||
punctuation_capitalization = PunctuationCapitalization(cfg.text_processing.punctuation_marks)
|
||||
if cfg.text_processing.do_lowercase:
|
||||
target_transcripts = punctuation_capitalization.do_lowercase(target_transcripts)
|
||||
if cfg.text_processing.rm_punctuation:
|
||||
target_transcripts = punctuation_capitalization.rm_punctuation(target_transcripts)
|
||||
if cfg.text_processing.separate_punctuation:
|
||||
target_transcripts = punctuation_capitalization.separate_punctuation(target_transcripts)
|
||||
|
||||
if cfg.probs_cache_file and os.path.exists(cfg.probs_cache_file):
|
||||
logging.info(f"Found a cached file of probabilities at '{cfg.probs_cache_file}'.")
|
||||
logging.info(f"Loading the cached file of probabilities from '{cfg.probs_cache_file}' ...")
|
||||
with open(cfg.probs_cache_file, 'rb') as probs_file:
|
||||
all_probs = msgpack.load(probs_file)
|
||||
|
||||
if len(all_probs) != len(audio_file_paths):
|
||||
raise ValueError(
|
||||
f"The number of samples in the probabilities file '{cfg.probs_cache_file}' does not "
|
||||
f"match the manifest file. You may need to delete the probabilities cached file."
|
||||
)
|
||||
else:
|
||||
|
||||
with torch.amp.autocast(asr_model.device.type, enabled=cfg.use_amp):
|
||||
with torch.no_grad():
|
||||
if isinstance(asr_model, EncDecHybridRNNTCTCModel):
|
||||
asr_model.cur_decoder = 'ctc'
|
||||
all_hyps = asr_model.transcribe(
|
||||
audio_file_paths, batch_size=cfg.acoustic_batch_size, return_hypotheses=True
|
||||
)
|
||||
all_logits = [h.y_sequence for h in all_hyps]
|
||||
|
||||
all_probs = all_logits
|
||||
if cfg.probs_cache_file:
|
||||
os.makedirs(os.path.split(cfg.probs_cache_file)[0], exist_ok=True)
|
||||
logging.info(f"Writing cached files of probabilities at '{cfg.probs_cache_file}'...")
|
||||
with open(cfg.probs_cache_file, 'wb') as f_dump:
|
||||
msgpack.dump(all_probs, f_dump)
|
||||
|
||||
wer_dist_greedy = 0
|
||||
cer_dist_greedy = 0
|
||||
words_count = 0
|
||||
chars_count = 0
|
||||
for batch_idx, probs in enumerate(all_probs):
|
||||
preds = np.argmax(probs, axis=1)
|
||||
preds_tensor = preds.to(device='cpu').unsqueeze(0)
|
||||
preds_lens = torch.tensor([preds_tensor.shape[1]], device='cpu')
|
||||
if isinstance(asr_model, EncDecHybridRNNTCTCModel):
|
||||
pred_text = asr_model.ctc_decoding.ctc_decoder_predictions_tensor(preds_tensor, preds_lens)[0]
|
||||
else:
|
||||
pred_text = asr_model.decoding.ctc_decoder_predictions_tensor(preds_tensor, preds_lens)[0]
|
||||
|
||||
if cfg.text_processing.do_lowercase:
|
||||
pred_text = punctuation_capitalization.do_lowercase([pred_text])[0]
|
||||
if cfg.text_processing.rm_punctuation:
|
||||
pred_text = punctuation_capitalization.rm_punctuation([pred_text])[0]
|
||||
if cfg.text_processing.separate_punctuation:
|
||||
pred_text = punctuation_capitalization.separate_punctuation([pred_text])[0]
|
||||
|
||||
pred_split_w = pred_text.split()
|
||||
target_split_w = target_transcripts[batch_idx].split()
|
||||
pred_split_c = list(pred_text)
|
||||
target_split_c = list(target_transcripts[batch_idx])
|
||||
|
||||
wer_dist = edit_distance(target_split_w, pred_split_w)['total']
|
||||
cer_dist = edit_distance(target_split_c, pred_split_c)['total']
|
||||
|
||||
wer_dist_greedy += wer_dist
|
||||
cer_dist_greedy += cer_dist
|
||||
words_count += len(target_split_w)
|
||||
chars_count += len(target_split_c)
|
||||
|
||||
logging.info('Greedy WER/CER = {:.2%}/{:.2%}'.format(wer_dist_greedy / words_count, cer_dist_greedy / chars_count))
|
||||
|
||||
asr_model = asr_model.to('cpu')
|
||||
|
||||
if (cfg.arpa_model_file is None or not os.path.exists(cfg.arpa_model_file)) and (
|
||||
cfg.decoding_wfst_file is None or not os.path.exists(cfg.decoding_wfst_file)
|
||||
):
|
||||
raise FileNotFoundError(
|
||||
f"Could not find both the ARPA model file `{cfg.arpa_model_file}` "
|
||||
f"and the decoding WFST file `{cfg.decoding_wfst_file}`."
|
||||
)
|
||||
|
||||
if cfg.beam_width is None or cfg.lm_weight is None:
|
||||
raise ValueError("beam_width and lm_weight are needed to perform WFST decoding.")
|
||||
params = {'beam_width': cfg.beam_width, 'lm_weight': cfg.lm_weight}
|
||||
hp_grid = ParameterGrid(params)
|
||||
hp_grid = list(hp_grid)
|
||||
|
||||
best_wer_beam_width, best_cer_beam_width = None, None
|
||||
best_wer_lm_weight, best_cer_lm_weight = None, None
|
||||
best_wer, best_cer = 1e6, 1e6
|
||||
|
||||
logging.info(f"==============================Starting the beam search decoding===============================")
|
||||
logging.info(f"Grid search size: {len(hp_grid)}")
|
||||
logging.info(f"It may take some time...")
|
||||
logging.info(f"==============================================================================================")
|
||||
|
||||
if cfg.preds_output_folder and not os.path.exists(cfg.preds_output_folder):
|
||||
os.mkdir(cfg.preds_output_folder)
|
||||
for hp in hp_grid:
|
||||
if cfg.preds_output_folder:
|
||||
preds_output_file = os.path.join(
|
||||
cfg.preds_output_folder,
|
||||
f"preds_out_beam_width{hp['beam_width']}_lm_weight{hp['lm_weight']}.tsv",
|
||||
)
|
||||
else:
|
||||
preds_output_file = None
|
||||
|
||||
candidate_wer, candidate_cer = beam_search_eval(
|
||||
asr_model,
|
||||
cfg,
|
||||
all_probs=all_probs,
|
||||
target_transcripts=target_transcripts,
|
||||
preds_output_file=preds_output_file,
|
||||
beam_width=hp["beam_width"],
|
||||
lm_weight=hp["lm_weight"],
|
||||
beam_batch_size=cfg.beam_batch_size,
|
||||
progress_bar=True,
|
||||
punctuation_capitalization=punctuation_capitalization,
|
||||
)
|
||||
|
||||
if candidate_cer < best_cer:
|
||||
best_cer_beam_width = hp["beam_width"]
|
||||
best_cer_lm_weight = hp["lm_weight"]
|
||||
best_cer = candidate_cer
|
||||
|
||||
if candidate_wer < best_wer:
|
||||
best_wer_beam_width = hp["beam_width"]
|
||||
best_wer_lm_weight = hp["lm_weight"]
|
||||
best_wer = candidate_wer
|
||||
|
||||
logging.info(
|
||||
f'Best WER Candidate = {best_wer:.2%} :: Beam size = {best_wer_beam_width}, LM weight = {best_wer_lm_weight}'
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f'Best CER Candidate = {best_cer:.2%} :: Beam size = {best_cer_beam_width}, LM weight = {best_cer_lm_weight}'
|
||||
)
|
||||
logging.info(f"=================================================================================")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Use this script to install KenLM, OpenSeq2Seq decoder, Flashlight decoder
|
||||
shopt -s expand_aliases
|
||||
|
||||
NEMO_PATH=/workspace/nemo # Path to NeMo folder: /workspace/nemo if you use NeMo/Dockerfile
|
||||
if [ "$#" -eq 1 ]; then
|
||||
NEMO_PATH=$1
|
||||
fi
|
||||
KENLM_MAX_ORDER=10 # Maximum order of KenLM model, also specified in the setup_os2s_decoders.py
|
||||
|
||||
if [ -d "$NEMO_PATH" ]; then
|
||||
echo "The folder '$NEMO_PATH' exists."
|
||||
else
|
||||
echo "Error: The folder '$NEMO_PATH' does not exist. Specify it as a first command line positional argument!"
|
||||
exit 1
|
||||
fi
|
||||
cd $NEMO_PATH
|
||||
|
||||
if [ $(id -u) -eq 0 ]; then
|
||||
alias aptupdate='apt-get update'
|
||||
alias b2install='./b2'
|
||||
else
|
||||
alias aptupdate='sudo apt-get update'
|
||||
alias b2install='sudo ./b2'
|
||||
fi
|
||||
|
||||
aptupdate && apt-get upgrade -y && apt-get install -y swig liblzma-dev && rm -rf /var/lib/apt/lists/* # liblzma needed for flashlight decoder
|
||||
|
||||
# install Boost package for KenLM
|
||||
wget https://archives.boost.io/release/1.80.0/source/boost_1_80_0.tar.bz2 && tar --bzip2 -xf $NEMO_PATH/boost_1_80_0.tar.bz2 && cd boost_1_80_0 && ./bootstrap.sh && b2install --layout=tagged link=static,shared threading=multi,single install -j4 && cd .. || echo FAILURE
|
||||
export BOOST_ROOT=$NEMO_PATH/boost_1_80_0
|
||||
|
||||
git clone https://github.com/NVIDIA/OpenSeq2Seq
|
||||
cd OpenSeq2Seq
|
||||
git checkout ctc-decoders
|
||||
cd ..
|
||||
mv OpenSeq2Seq/decoders $NEMO_PATH/
|
||||
rm -rf OpenSeq2Seq
|
||||
cd $NEMO_PATH/decoders
|
||||
cp $NEMO_PATH/scripts/installers/setup_os2s_decoders.py ./setup.py
|
||||
./setup.sh
|
||||
|
||||
# install KenLM
|
||||
cd $NEMO_PATH/decoders/kenlm/build && cmake -DKENLM_MAX_ORDER=$KENLM_MAX_ORDER .. && make -j2
|
||||
cd $NEMO_PATH/decoders/kenlm
|
||||
python setup.py install --max_order=$KENLM_MAX_ORDER
|
||||
export KENLM_LIB=$NEMO_PATH/decoders/kenlm/build/bin
|
||||
export KENLM_ROOT=$NEMO_PATH/decoders/kenlm
|
||||
cd ..
|
||||
|
||||
# install Flashlight
|
||||
git clone https://github.com/flashlight/text && cd text
|
||||
python setup.py bdist_wheel
|
||||
pip install dist/*.whl
|
||||
cd ..
|
||||
+944
@@ -0,0 +1,944 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Copyright (c) 2016, Johns Hopkins University (Author: Daniel Povey).
|
||||
# All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# This script was copied from https://github.com/kaldi-asr/kaldi/blob/master/egs/wsj/s5/utils/lang/make_phone_lm.py
|
||||
# with minor python3 related changes.
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
# note, this was originally based
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="""
|
||||
This script creates a language model that's intended to be used in modeling
|
||||
phone sequences (either of sentences or of dictionary entries), although of
|
||||
course it will work for any type of data. The easiest way
|
||||
to describe it is as a a Kneser-Ney language model (unmodified, with addition)
|
||||
with a fixed discounting constant equal to 1, except with no smoothing of the
|
||||
bigrams (and hence no unigram state). This is (a) because we want to keep the
|
||||
graph after context expansion small, (b) because languages tend to have
|
||||
constraints on which phones can follow each other, and (c) in order to get valid
|
||||
sequences of word-position-dependent phones so that lattice-align-words can
|
||||
work. It also includes have a special entropy-based pruning technique that
|
||||
backs off the statistics of pruned n-grams to lower-order states.
|
||||
This script reads lines from its standard input, each
|
||||
consisting of a sequence of integer symbol-ids (which should be > 0),
|
||||
representing the phone sequences of a sentence or dictionary entry.
|
||||
This script outputs a backoff language model in FST format""",
|
||||
epilog="See also utils/lang/make_phone_bigram_lang.sh",
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
"--phone-disambig-symbol",
|
||||
type=int,
|
||||
required=False,
|
||||
help="Integer corresponding to an otherwise-unused "
|
||||
"phone-level disambiguation symbol (e.g. #5). This is "
|
||||
"inserted at the beginning of the phone sequence and "
|
||||
"whenever we back off.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ngram-order",
|
||||
type=int,
|
||||
default=4,
|
||||
choices=[2, 3, 4, 5, 6, 7],
|
||||
help="Order of n-gram to use (but see also --num-extra-states;" "the effective order after pruning may be less.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-extra-ngrams",
|
||||
type=int,
|
||||
default=20000,
|
||||
help="Target number of n-grams in addition to the n-grams in "
|
||||
"the bigram LM states which can't be pruned away. n-grams "
|
||||
"will be pruned to reach this target.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-backoff-ngram-order",
|
||||
type=int,
|
||||
default=2,
|
||||
choices=[1, 2, 3, 4, 5],
|
||||
help="This specifies the n-gram order at which (and below which) "
|
||||
"no backoff or pruning should be done. This is expected to normally "
|
||||
"be bigram, but for testing purposes you may want to set it to "
|
||||
"1.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--print-as-arpa",
|
||||
type=str,
|
||||
default="false",
|
||||
choices=["true", "false"],
|
||||
help="If true, print LM in ARPA format (default is to print "
|
||||
"as FST). You must also set --no-backoff-ngram-order=1 or "
|
||||
"this is not allowed.",
|
||||
)
|
||||
parser.add_argument("--verbose", type=int, default=0, choices=[0, 1, 2, 3, 4, 5], help="Verbose level")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose >= 1:
|
||||
print(" ".join(sys.argv), file=sys.stderr)
|
||||
|
||||
|
||||
class CountsForHistory(object):
|
||||
## This class (which is more like a struct) stores the counts seen in a
|
||||
## particular history-state. It is used inside class NgramCounts.
|
||||
## It really does the job of a dict from int to float, but it also
|
||||
## keeps track of the total count.
|
||||
def __init__(self):
|
||||
# The 'lambda: defaultdict(float)' is an anonymous function taking no
|
||||
# arguments that returns a new defaultdict(float).
|
||||
self.word_to_count = defaultdict(int)
|
||||
self.total_count = 0
|
||||
|
||||
def Words(self):
|
||||
return list(self.word_to_count.keys())
|
||||
|
||||
def __str__(self):
|
||||
# e.g. returns ' total=12 3->4 4->6 -1->2'
|
||||
return " total={0} {1}".format(
|
||||
str(self.total_count),
|
||||
" ".join(["{0} -> {1}".format(word, count) for word, count in self.word_to_count.items()]),
|
||||
)
|
||||
|
||||
## Adds a certain count (expected to be integer, but might be negative). If
|
||||
## the resulting count for this word is zero, removes the dict entry from
|
||||
## word_to_count.
|
||||
## [note, though, that in some circumstances we 'add back' zero counts
|
||||
## where the presence of n-grams would be structurally required by the arpa,
|
||||
## specifically if a higher-order history state has a nonzero count,
|
||||
## we need to structurally have the count there in the states it backs
|
||||
## off to.
|
||||
def AddCount(self, predicted_word, count):
|
||||
self.total_count += count
|
||||
assert self.total_count >= 0
|
||||
old_count = self.word_to_count[predicted_word]
|
||||
new_count = old_count + count
|
||||
if new_count < 0:
|
||||
print("predicted-word={0}, old-count={1}, count={2}".format(predicted_word, old_count, count))
|
||||
assert new_count >= 0
|
||||
if new_count == 0:
|
||||
del self.word_to_count[predicted_word]
|
||||
else:
|
||||
self.word_to_count[predicted_word] = new_count
|
||||
|
||||
|
||||
class NgramCounts(object):
|
||||
## A note on data-structure. Firstly, all words are represented as
|
||||
## integers. We store n-gram counts as an array, indexed by (history-length
|
||||
## == n-gram order minus one) (note: python calls arrays "lists") of dicts
|
||||
## from histories to counts, where histories are arrays of integers and
|
||||
## "counts" are dicts from integer to float. For instance, when
|
||||
## accumulating the 4-gram count for the '8' in the sequence '5 6 7 8', we'd
|
||||
## do as follows: self.counts[3][[5,6,7]][8] += 1.0 where the [3] indexes an
|
||||
## array, the [[5,6,7]] indexes a dict, and the [8] indexes a dict.
|
||||
def __init__(self, ngram_order):
|
||||
assert ngram_order >= 2
|
||||
# Integerized counts will never contain negative numbers, so
|
||||
# inside this program, we use -3 and -2 for the BOS and EOS symbols
|
||||
# respectively.
|
||||
# Note: it's actually important that the bos-symbol is the most negative;
|
||||
# it helps ensure that we print the state with left-context <s> first
|
||||
# when we print the FST, and this means that the start-state will have
|
||||
# the correct value.
|
||||
self.bos_symbol = -3
|
||||
self.eos_symbol = -2
|
||||
# backoff_symbol is kind of a pseudo-word, it's used in keeping track of
|
||||
# the backoff counts in each state.
|
||||
self.backoff_symbol = -1
|
||||
self.total_num_words = 0 # count includes EOS but not BOS.
|
||||
self.counts = []
|
||||
for n in range(ngram_order):
|
||||
self.counts.append(defaultdict(lambda: CountsForHistory()))
|
||||
|
||||
# adds a raw count (called while processing input data).
|
||||
# Suppose we see the sequence '6 7 8 9' and ngram_order=4, 'history'
|
||||
# would be (6,7,8) and 'predicted_word' would be 9; 'count' would be
|
||||
# 1.
|
||||
def AddCount(self, history, predicted_word, count):
|
||||
self.counts[len(history)][history].AddCount(predicted_word, count)
|
||||
|
||||
# 'line' is a string containing a sequence of integer word-ids.
|
||||
# This function adds the un-smoothed counts from this line of text.
|
||||
def AddRawCountsFromLine(self, line):
|
||||
try:
|
||||
words = [self.bos_symbol] + [int(x) for x in line.split()] + [self.eos_symbol]
|
||||
except Exception:
|
||||
sys.exit("make_phone_lm.py: bad input line {0} (expected a sequence " "of integers)".format(line))
|
||||
|
||||
for n in range(1, len(words)):
|
||||
predicted_word = words[n]
|
||||
history_start = max(0, n + 1 - args.ngram_order)
|
||||
history = tuple(words[history_start:n])
|
||||
self.AddCount(history, predicted_word, 1)
|
||||
self.total_num_words += 1
|
||||
|
||||
def AddRawCountsFromStandardInput(self):
|
||||
lines_processed = 0
|
||||
while True:
|
||||
line = sys.stdin.readline()
|
||||
if line == "":
|
||||
break
|
||||
self.AddRawCountsFromLine(line)
|
||||
lines_processed += 1
|
||||
if lines_processed == 0 or args.verbose > 0:
|
||||
print(
|
||||
"make_phone_lm.py: processed {0} lines of input".format(lines_processed),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# This backs off the counts by subtracting 1 and assigning the subtracted
|
||||
# count to the backoff state. It's like a special case of Kneser-Ney with D
|
||||
# = 1. The optimal D would likely be something like 0.9, but we plan to
|
||||
# later do entropy-pruning, and the remaining small counts of 0.1 would
|
||||
# essentially all get pruned away anyway, so we don't lose much by doing it
|
||||
# like this.
|
||||
def ApplyBackoff(self):
|
||||
# note: in the normal case where args.no_backoff_ngram_order == 2 we
|
||||
# don't do backoff for history-length = 1 (i.e. for bigrams)... this is
|
||||
# a kind of special LM where we're not going to back off to unigram,
|
||||
# there will be no unigram.
|
||||
if args.verbose >= 1:
|
||||
initial_num_ngrams = self.GetNumNgrams()
|
||||
for n in reversed(list(range(args.no_backoff_ngram_order, args.ngram_order))):
|
||||
this_order_counts = self.counts[n]
|
||||
for hist, counts_for_hist in this_order_counts.items():
|
||||
backoff_hist = hist[1:]
|
||||
backoff_counts_for_hist = self.counts[n - 1][backoff_hist]
|
||||
this_discount_total = 0
|
||||
for word in counts_for_hist.Words():
|
||||
counts_for_hist.AddCount(word, -1)
|
||||
# You can interpret the following line as incrementing the
|
||||
# count-of-counts for the next-lower order. Note, however,
|
||||
# that later when we remove n-grams, we'll also add their
|
||||
# counts to the next-lower-order history state, so the
|
||||
# resulting counts won't strictly speaking be
|
||||
# counts-of-counts.
|
||||
backoff_counts_for_hist.AddCount(word, 1)
|
||||
this_discount_total += 1
|
||||
counts_for_hist.AddCount(self.backoff_symbol, this_discount_total)
|
||||
|
||||
if args.verbose >= 1:
|
||||
# Note: because D == 1, we completely back off singletons.
|
||||
print(
|
||||
"make_phone_lm.py: ApplyBackoff() reduced the num-ngrams from "
|
||||
"{0} to {1}".format(initial_num_ngrams, self.GetNumNgrams()),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# This function prints out to stderr the n-gram counts stored in this
|
||||
# object; it's used for debugging.
|
||||
def Print(self, info_string):
|
||||
print(info_string, file=sys.stderr)
|
||||
# these are useful for debug.
|
||||
total = 0.0
|
||||
total_excluding_backoff = 0.0
|
||||
for this_order_counts in self.counts:
|
||||
for hist, counts_for_hist in this_order_counts.items():
|
||||
print(str(hist) + str(counts_for_hist), file=sys.stderr)
|
||||
total += counts_for_hist.total_count
|
||||
total_excluding_backoff += counts_for_hist.total_count
|
||||
if self.backoff_symbol in counts_for_hist.word_to_count:
|
||||
total_excluding_backoff -= counts_for_hist.word_to_count[self.backoff_symbol]
|
||||
print(
|
||||
"total count = {0}, excluding backoff = {1}".format(total, total_excluding_backoff),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
def GetHistToStateMap(self):
|
||||
# This function, called from PrintAsFst, returns a map from
|
||||
# history to integer FST-state.
|
||||
hist_to_state = dict()
|
||||
fst_state_counter = 0
|
||||
for n in range(0, args.ngram_order):
|
||||
for hist in self.counts[n].keys():
|
||||
hist_to_state[hist] = fst_state_counter
|
||||
fst_state_counter += 1
|
||||
return hist_to_state
|
||||
|
||||
# Returns the probability of word 'word' in history-state 'hist'.
|
||||
# If 'word' is self.backoff_symbol, returns the backoff prob
|
||||
# of this history-state.
|
||||
# Returns None if there is no such word in this history-state, or this
|
||||
# history-state does not exist.
|
||||
def GetProb(self, hist, word):
|
||||
if len(hist) >= args.ngram_order or not hist in self.counts[len(hist)]:
|
||||
return None
|
||||
counts_for_hist = self.counts[len(hist)][hist]
|
||||
total_count = float(counts_for_hist.total_count)
|
||||
if not word in counts_for_hist.word_to_count:
|
||||
print(
|
||||
"make_phone_lm.py: no prob for {0} -> {1} " "[no such count]".format(hist, word),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return None
|
||||
prob = float(counts_for_hist.word_to_count[word]) / total_count
|
||||
if len(hist) > 0 and word != self.backoff_symbol and self.backoff_symbol in counts_for_hist.word_to_count:
|
||||
prob_in_backoff = self.GetProb(hist[1:], word)
|
||||
backoff_prob = float(counts_for_hist.word_to_count[self.backoff_symbol]) / total_count
|
||||
try:
|
||||
prob += backoff_prob * prob_in_backoff
|
||||
except Exception:
|
||||
sys.exit("problem, hist is {0}, word is {1}".format(hist, word))
|
||||
return prob
|
||||
|
||||
def PruneEmptyStates(self):
|
||||
# Removes history-states that have no counts.
|
||||
|
||||
# It's possible in principle for history-states to have no counts and
|
||||
# yet they cannot be pruned away because a higher-order version of the
|
||||
# state exists with nonzero counts, so we have to keep track of this.
|
||||
protected_histories = set()
|
||||
|
||||
states_removed_per_hist_len = [0] * args.ngram_order
|
||||
|
||||
for n in reversed(list(range(args.no_backoff_ngram_order, args.ngram_order))):
|
||||
num_states_removed = 0
|
||||
for hist, counts_for_hist in self.counts[n].items():
|
||||
l = len(counts_for_hist.word_to_count)
|
||||
assert l > 0 and self.backoff_symbol in counts_for_hist.word_to_count
|
||||
if l == 1 and not hist in protected_histories: # only the backoff symbol has a count.
|
||||
del self.counts[n][hist]
|
||||
num_states_removed += 1
|
||||
else:
|
||||
# if this state was not pruned away, then the state that
|
||||
# it backs off to may not be pruned away either.
|
||||
backoff_hist = hist[1:]
|
||||
protected_histories.add(backoff_hist)
|
||||
states_removed_per_hist_len[n] = num_states_removed
|
||||
if args.verbose >= 1:
|
||||
print(
|
||||
"make_phone_lm.py: in PruneEmptyStates(), num states removed for "
|
||||
"each history-length was: " + str(states_removed_per_hist_len),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
def EnsureStructurallyNeededNgramsExist(self):
|
||||
# makes sure that if an n-gram like (6, 7, 8) -> 9 exists,
|
||||
# then counts exist for (7, 8) -> 9 and (8,) -> 9. It does so
|
||||
# by adding zero counts where such counts were absent.
|
||||
# [note: () -> 9 is guaranteed anyway by the backoff method, if
|
||||
# we have a unigram state].
|
||||
if args.verbose >= 1:
|
||||
num_ngrams_initial = self.GetNumNgrams()
|
||||
for n in reversed(list(range(args.no_backoff_ngram_order, args.ngram_order))):
|
||||
|
||||
for hist, counts_for_hist in self.counts[n].items():
|
||||
# This loop ensures that if we have an n-gram like (6, 7, 8) -> 9,
|
||||
# then, say, (7, 8) -> 9 and (8) -> 9 exist.
|
||||
reduced_hist = hist
|
||||
for m in reversed(list(range(args.no_backoff_ngram_order, n))):
|
||||
reduced_hist = reduced_hist[1:] # shift an element off
|
||||
# the history.
|
||||
counts_for_backoff_hist = self.counts[m][reduced_hist]
|
||||
for word in counts_for_hist.word_to_count.keys():
|
||||
counts_for_backoff_hist.word_to_count[word] += 0
|
||||
# This loop ensures that if we have an n-gram like (6, 7, 8) -> 9,
|
||||
# then, say, (6, 7) -> 8 and (6) -> 7 exist. This will be needed
|
||||
# for FST representations of the ARPA LM.
|
||||
reduced_hist = hist
|
||||
for m in reversed(list(range(args.no_backoff_ngram_order, n))):
|
||||
this_word = reduced_hist[-1]
|
||||
reduced_hist = reduced_hist[:-1] # pop an element off the
|
||||
# history
|
||||
counts_for_backoff_hist = self.counts[m][reduced_hist]
|
||||
counts_for_backoff_hist.word_to_count[this_word] += 0
|
||||
if args.verbose >= 1:
|
||||
print(
|
||||
"make_phone_lm.py: in EnsureStructurallyNeededNgramsExist(), "
|
||||
"added {0} n-grams".format(self.GetNumNgrams() - num_ngrams_initial),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# This function prints the estimated language model as an FST.
|
||||
def PrintAsFst(self, word_disambig_symbol):
|
||||
# n is the history-length (== order + 1). We iterate over the
|
||||
# history-length in the order 1, 0, 2, 3, and then iterate over the
|
||||
# histories of each order in sorted order. Putting order 1 first
|
||||
# and sorting on the histories
|
||||
# ensures that the bigram state with <s> as the left context comes first.
|
||||
# (note: self.bos_symbol is the most negative symbol)
|
||||
|
||||
# History will map from history (as a tuple) to integer FST-state.
|
||||
hist_to_state = self.GetHistToStateMap()
|
||||
|
||||
for n in [1, 0] + list(range(2, args.ngram_order)):
|
||||
this_order_counts = self.counts[n]
|
||||
# For order 1, make sure the keys are sorted.
|
||||
keys = this_order_counts.keys() if n != 1 else sorted(this_order_counts.keys())
|
||||
for hist in keys:
|
||||
word_to_count = this_order_counts[hist].word_to_count
|
||||
this_fst_state = hist_to_state[hist]
|
||||
|
||||
for word in word_to_count.keys():
|
||||
# work out this_cost. Costs in OpenFst are negative logs.
|
||||
this_cost = -math.log(self.GetProb(hist, word))
|
||||
|
||||
if word > 0: # a real word.
|
||||
next_hist = hist + (word,) # appending tuples
|
||||
while not next_hist in hist_to_state:
|
||||
next_hist = next_hist[1:]
|
||||
next_fst_state = hist_to_state[next_hist]
|
||||
print(this_fst_state, next_fst_state, word, word, this_cost)
|
||||
elif word == self.eos_symbol:
|
||||
# print final-prob for this state.
|
||||
print(this_fst_state, this_cost)
|
||||
else:
|
||||
assert word == self.backoff_symbol
|
||||
backoff_fst_state = hist_to_state[hist[1 : len(hist)]]
|
||||
print(
|
||||
this_fst_state,
|
||||
backoff_fst_state,
|
||||
word_disambig_symbol,
|
||||
0,
|
||||
this_cost,
|
||||
)
|
||||
|
||||
# This function returns a set of n-grams that cannot currently be pruned
|
||||
# away, either because a higher-order form of the same n-gram already exists,
|
||||
# or because the n-gram leads to an n-gram state that exists.
|
||||
# [Note: as we prune, we remove any states that can be removed; see that
|
||||
# PruneToIntermediateTarget() calls PruneEmptyStates().
|
||||
|
||||
def GetProtectedNgrams(self):
|
||||
ans = set()
|
||||
for n in range(args.no_backoff_ngram_order + 1, args.ngram_order):
|
||||
for hist, counts_for_hist in self.counts[n].items():
|
||||
# If we have an n-gram (6, 7, 8) -> 9, the following loop will
|
||||
# add the backed-off n-grams (7, 8) -> 9 and (8) -> 9 to
|
||||
# 'protected-ngrams'.
|
||||
reduced_hist = hist
|
||||
for _ in reversed(list(range(args.no_backoff_ngram_order, n))):
|
||||
reduced_hist = reduced_hist[1:] # shift an element off
|
||||
# the history.
|
||||
|
||||
for word in counts_for_hist.word_to_count.keys():
|
||||
if word != self.backoff_symbol:
|
||||
ans.add(reduced_hist + (word,))
|
||||
# The following statement ensures that if we are in a
|
||||
# history-state (6, 7, 8), then n-grams (6, 7, 8) and (6, 7) are
|
||||
# protected. This assures that the FST states are accessible.
|
||||
reduced_hist = hist
|
||||
for _ in reversed(list(range(args.no_backoff_ngram_order, n))):
|
||||
ans.add(reduced_hist)
|
||||
reduced_hist = reduced_hist[:-1] # pop an element off the
|
||||
# history
|
||||
return ans
|
||||
|
||||
def PruneNgram(self, hist, word):
|
||||
counts_for_hist = self.counts[len(hist)][hist]
|
||||
assert word != self.backoff_symbol and word in counts_for_hist.word_to_count
|
||||
count = counts_for_hist.word_to_count[word]
|
||||
del counts_for_hist.word_to_count[word]
|
||||
counts_for_hist.word_to_count[self.backoff_symbol] += count
|
||||
# the next call adds the count to the symbol 'word' in the backoff
|
||||
# history-state, and also updates its 'total_count'.
|
||||
self.counts[len(hist) - 1][hist[1:]].AddCount(word, count)
|
||||
|
||||
# The function PruningLogprobChange is the same as the same-named
|
||||
# function in float-counts-prune.cc in pocolm. Note, it doesn't access
|
||||
# any class members.
|
||||
|
||||
# This function computes the log-likelihood change (<= 0) from backing off
|
||||
# a particular symbol to the lower-order state.
|
||||
# The value it returns can be interpreted as a lower bound the actual log-likelihood
|
||||
# change. By "the actual log-likelihood change" we mean of data generated by
|
||||
# the model itself before making the change, then modeled with the changed model
|
||||
# [and comparing the log-like with the log-like before changing the model]. That is,
|
||||
# it's a K-L divergence, but with the caveat that we don't normalize by the
|
||||
# overall count of the data, so it's a K-L divergence multiplied by the training-data
|
||||
# count.
|
||||
|
||||
# 'count' is the count of the word (call it 'a') in this state. It's an integer.
|
||||
# 'discount' is the discount-count in this state (represented as the count
|
||||
# for the symbol self.backoff_symbol). It's an integer.
|
||||
# [note: we don't care about the total-count in this state, it cancels out.]
|
||||
# 'backoff_count' is the count of word 'a' in the lower-order state.
|
||||
# [actually it is the augmented count, treating any
|
||||
# extra probability from even-lower-order states as
|
||||
# if it were a count]. It's a float.
|
||||
# 'backoff_total' is the total count in the lower-order state. It's a float.
|
||||
def PruningLogprobChange(self, count, discount, backoff_count, backoff_total):
|
||||
if count == 0:
|
||||
return 0.0
|
||||
|
||||
assert discount > 0 and backoff_total >= backoff_count and backoff_total >= 0.99 * discount
|
||||
|
||||
# augmented_count is like 'count', but with the extra count for symbol
|
||||
# 'a' due to backoff included.
|
||||
augmented_count = count + discount * backoff_count / backoff_total
|
||||
|
||||
# We imagine a phantom symbol 'b' that represents all symbols other than
|
||||
# 'a' appearing in this history-state that are accessed via backoff. We
|
||||
# treat these as being distinct symbols from the same symbol if accessed
|
||||
# not-via-backoff. (Treating same symbols as distinct gives an upper bound
|
||||
# on the divergence). We also treat them as distinct from the same symbols
|
||||
# that are being accessed via backoff from other states. b_count is the
|
||||
# observed count of symbol 'b' in this state (the backed-off count is
|
||||
# zero). b_count is also the count of symbol 'b' in the backoff state.
|
||||
# Note: b_count will not be negative because backoff_total >= backoff_count.
|
||||
b_count = discount * ((backoff_total - backoff_count) / backoff_total)
|
||||
assert b_count >= -0.001 * backoff_total
|
||||
|
||||
# We imagine a phantom symbol 'c' that represents all symbols other than
|
||||
# 'a' and 'b' appearing in the backoff state, which got there from
|
||||
# backing off other states (other than 'this' state). Again, we imagine
|
||||
# the symbols are distinct even though they may not be (i.e. that c and
|
||||
# b represent disjoint sets of symbol, even though they might not really
|
||||
# be disjoint), and this gives us an upper bound on the divergence.
|
||||
c_count = backoff_total - backoff_count - b_count
|
||||
assert c_count >= -0.001 * backoff_total
|
||||
|
||||
# a_other is the count of 'a' in the backoff state that comes from
|
||||
# 'other sources', i.e. it was backed off from history-states other than
|
||||
# the current history state.
|
||||
a_other_count = backoff_count - discount * backoff_count / backoff_total
|
||||
assert a_other_count >= -0.001 * backoff_count
|
||||
|
||||
# the following sub-expressions are the 'new' versions of certain
|
||||
# quantities after we assign the total count 'count' to backoff. it
|
||||
# increases the backoff count in 'this' state, and also the total count
|
||||
# in the backoff state, and the count of symbol 'a' in the backoff
|
||||
# state.
|
||||
new_backoff_count = backoff_count + count # new count of symbol 'a' in
|
||||
# backoff state
|
||||
new_backoff_total = backoff_total + count # new total count in
|
||||
# backoff state.
|
||||
new_discount = discount + count # new discount-count in 'this' state.
|
||||
|
||||
# all the loglike changes below are of the form
|
||||
# count-of-symbol * log(new prob / old prob)
|
||||
# which can be more conveniently written (by canceling the denominators),
|
||||
# count-of-symbol * log(new count / old count).
|
||||
|
||||
# this_a_change is the log-like change of symbol 'a' coming from 'this'
|
||||
# state. bear in mind that
|
||||
# augmented_count = count + discount * backoff_count / backoff_total,
|
||||
# and the 'count' term is zero in the numerator part of the log expression,
|
||||
# because symbol 'a' is completely backed off in 'this' state.
|
||||
this_a_change = augmented_count * math.log(
|
||||
(new_discount * new_backoff_count / new_backoff_total) / augmented_count
|
||||
)
|
||||
|
||||
# other_a_change is the log-like change of symbol 'a' coming from all
|
||||
# other states than 'this'. For speed reasons we don't examine the
|
||||
# direct (non-backoff) counts of symbol 'a' in all other states than
|
||||
# 'this' that back off to the backoff state-- it would be slower.
|
||||
# Instead we just treat the direct part of the prob for symbol 'a' as a
|
||||
# distinct symbol when it comes from those other states... as usual,
|
||||
# doing so gives us an upper bound on the divergence.
|
||||
other_a_change = a_other_count * math.log(
|
||||
(new_backoff_count / new_backoff_total) / (backoff_count / backoff_total)
|
||||
)
|
||||
|
||||
# b_change is the log-like change of phantom symbol 'b' coming from
|
||||
# 'this' state (and note: it only comes from this state, that's how we
|
||||
# defined it).
|
||||
# note: the expression below could be more directly written as a
|
||||
# ratio of pseudo-counts as follows, by converting the backoff probabilities
|
||||
# into pseudo-counts in 'this' state:
|
||||
# b_count * logf((new_discount * b_count / new_backoff_total) /
|
||||
# (discount * b_count / backoff_total),
|
||||
# but we cancel b_count to give us the expression below.
|
||||
b_change = b_count * math.log((new_discount / new_backoff_total) / (discount / backoff_total))
|
||||
|
||||
# c_change is the log-like change of phantom symbol 'c' coming from
|
||||
# all other states that back off to the backoff sate (and all prob. mass of
|
||||
# 'c' comes from those other states). The expression below could be more
|
||||
# directly written as a ratio of counts, as c_count * logf((c_count /
|
||||
# new_backoff_total) / (c_count / backoff_total)), but we simplified it to
|
||||
# the expression below.
|
||||
c_change = c_count * math.log(backoff_total / new_backoff_total)
|
||||
|
||||
ans = this_a_change + other_a_change + b_change + c_change
|
||||
# the answer should not be positive.
|
||||
assert ans <= 0.0001 * (count + discount + backoff_count + backoff_total)
|
||||
if args.verbose >= 4:
|
||||
print(
|
||||
"pruning-logprob-change for {0},{1},{2},{3} is {4}".format(
|
||||
count, discount, backoff_count, backoff_total, ans
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return ans
|
||||
|
||||
def GetLikeChangeFromPruningNgram(self, hist, word):
|
||||
counts_for_hist = self.counts[len(hist)][hist]
|
||||
counts_for_backoff_hist = self.counts[len(hist) - 1][hist[1:]]
|
||||
assert word != self.backoff_symbol and word in counts_for_hist.word_to_count
|
||||
count = counts_for_hist.word_to_count[word]
|
||||
discount = counts_for_hist.word_to_count[self.backoff_symbol]
|
||||
backoff_total = counts_for_backoff_hist.total_count
|
||||
# backoff_count is a pseudo-count: it's like the count of 'word' in the
|
||||
# backoff history-state, but adding something to account for further
|
||||
# levels of backoff.
|
||||
try:
|
||||
backoff_count = self.GetProb(hist[1:], word) * backoff_total
|
||||
except Exception:
|
||||
print(
|
||||
"problem getting backoff count: hist = {0}, word = {1}".format(hist, word),
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return self.PruningLogprobChange(float(count), float(discount), backoff_count, float(backoff_total))
|
||||
|
||||
# note: returns loglike change per word.
|
||||
def PruneToIntermediateTarget(self, num_extra_ngrams):
|
||||
protected_ngrams = self.GetProtectedNgrams()
|
||||
initial_num_extra_ngrams = self.GetNumExtraNgrams()
|
||||
num_ngrams_to_prune = initial_num_extra_ngrams - num_extra_ngrams
|
||||
assert num_ngrams_to_prune > 0
|
||||
|
||||
num_candidates_per_order = [0] * args.ngram_order
|
||||
num_pruned_per_order = [0] * args.ngram_order
|
||||
|
||||
# like_change_and_ngrams this will be a list of tuples consisting
|
||||
# of the likelihood change as a float and then the words of the n-gram
|
||||
# that we're considering pruning,
|
||||
# e.g. (-0.164, 7, 8, 9)
|
||||
# meaning that pruning the n-gram (7, 8) -> 9 leads to
|
||||
# a likelihood change of -0.164. We'll later sort this list
|
||||
# so we can prune the n-grams that made the least-negative
|
||||
# likelihood change.
|
||||
like_change_and_ngrams = []
|
||||
for n in range(args.no_backoff_ngram_order, args.ngram_order):
|
||||
for hist, counts_for_hist in self.counts[n].items():
|
||||
for word, count in counts_for_hist.word_to_count.items():
|
||||
if word != self.backoff_symbol:
|
||||
if not hist + (word,) in protected_ngrams:
|
||||
like_change = self.GetLikeChangeFromPruningNgram(hist, word)
|
||||
like_change_and_ngrams.append((like_change,) + hist + (word,))
|
||||
num_candidates_per_order[len(hist)] += 1
|
||||
|
||||
like_change_and_ngrams.sort(reverse=True)
|
||||
|
||||
if num_ngrams_to_prune > len(like_change_and_ngrams):
|
||||
print(
|
||||
"make_phone_lm.py: aimed to prune {0} n-grams but could only "
|
||||
"prune {1}".format(num_ngrams_to_prune, len(like_change_and_ngrams)),
|
||||
file=sys.stderr,
|
||||
)
|
||||
num_ngrams_to_prune = len(like_change_and_ngrams)
|
||||
|
||||
total_loglike_change = 0.0
|
||||
|
||||
for i in range(num_ngrams_to_prune):
|
||||
total_loglike_change += like_change_and_ngrams[i][0]
|
||||
hist = like_change_and_ngrams[i][1:-1] # all but 1st and last elements
|
||||
word = like_change_and_ngrams[i][-1] # last element
|
||||
num_pruned_per_order[len(hist)] += 1
|
||||
self.PruneNgram(hist, word)
|
||||
|
||||
like_change_per_word = total_loglike_change / self.total_num_words
|
||||
|
||||
if args.verbose >= 1:
|
||||
effective_threshold = (
|
||||
like_change_and_ngrams[num_ngrams_to_prune - 1][0] if num_ngrams_to_prune >= 0 else 0.0
|
||||
)
|
||||
print(
|
||||
"Pruned from {0} ngrams to {1}, with threshold {2}. Candidates per order were {3}, "
|
||||
"num-ngrams pruned per order were {4}. Like-change per word was {5}".format(
|
||||
initial_num_extra_ngrams,
|
||||
initial_num_extra_ngrams - num_ngrams_to_prune,
|
||||
"%.4f" % effective_threshold,
|
||||
num_candidates_per_order,
|
||||
num_pruned_per_order,
|
||||
like_change_per_word,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if args.verbose >= 3:
|
||||
print(
|
||||
"Pruning: like_change_and_ngrams is:\n"
|
||||
+ "\n".join([str(x) for x in like_change_and_ngrams[:num_ngrams_to_prune]])
|
||||
+ "\n-------- stop pruning here: ----------\n"
|
||||
+ "\n".join([str(x) for x in like_change_and_ngrams[num_ngrams_to_prune:]]),
|
||||
file=sys.stderr,
|
||||
)
|
||||
self.Print(
|
||||
"Counts after pruning to num-extra-ngrams={0}".format(initial_num_extra_ngrams - num_ngrams_to_prune)
|
||||
)
|
||||
|
||||
self.PruneEmptyStates()
|
||||
if args.verbose >= 3:
|
||||
ngram_counts.Print("Counts after removing empty states [inside pruning algorithm]:")
|
||||
return like_change_per_word
|
||||
|
||||
def PruneToFinalTarget(self, num_extra_ngrams):
|
||||
# prunes to a specified num_extra_ngrams. The 'extra_ngrams' refers to
|
||||
# the count of n-grams of order higher than args.no_backoff_ngram_order.
|
||||
# We construct a sequence of targets that gradually approaches
|
||||
# this value. Doing it iteratively like this is a good way
|
||||
# to deal with the fact that sometimes we can't prune a certain
|
||||
# n-gram before certain other n-grams are pruned (because
|
||||
# they lead to a state that must be kept, or an n-gram exists
|
||||
# that backs off to this n-gram).
|
||||
|
||||
current_num_extra_ngrams = self.GetNumExtraNgrams()
|
||||
|
||||
if num_extra_ngrams >= current_num_extra_ngrams:
|
||||
print(
|
||||
"make_phone_lm.py: not pruning since target num-extra-ngrams={0} is >= "
|
||||
"current num-extra-ngrams={1}".format(num_extra_ngrams, current_num_extra_ngrams),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
target_sequence = [num_extra_ngrams]
|
||||
# two final iterations where the targets differ by factors of 1.1,
|
||||
# preceded by two iterations where the targets differ by factors of 1.2.
|
||||
for this_factor in [1.1, 1.2]:
|
||||
for n in range(0, 2):
|
||||
if int((target_sequence[-1] + 1) * this_factor) < current_num_extra_ngrams:
|
||||
target_sequence.append(int((target_sequence[-1] + 1) * this_factor))
|
||||
# then change in factors of 1.3
|
||||
while True:
|
||||
this_factor = 1.3
|
||||
if int((target_sequence[-1] + 1) * this_factor) < current_num_extra_ngrams:
|
||||
target_sequence.append(int((target_sequence[-1] + 1) * this_factor))
|
||||
else:
|
||||
break
|
||||
|
||||
target_sequence = list(set(target_sequence)) # only keep unique targets.
|
||||
target_sequence.sort(reverse=True)
|
||||
|
||||
print(
|
||||
"make_phone_lm.py: current num-extra-ngrams={0}, pruning with "
|
||||
"following sequence of targets: {1}".format(current_num_extra_ngrams, target_sequence),
|
||||
file=sys.stderr,
|
||||
)
|
||||
total_like_change_per_word = 0.0
|
||||
for target in target_sequence:
|
||||
total_like_change_per_word += self.PruneToIntermediateTarget(target)
|
||||
|
||||
if args.verbose >= 1:
|
||||
print(
|
||||
"make_phone_lm.py: K-L divergence from pruning (upper bound) is " "%.4f" % total_like_change_per_word,
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# returns the number of n-grams on top of those that can't be pruned away
|
||||
# because their order is <= args.no_backoff_ngram_order.
|
||||
def GetNumExtraNgrams(self):
|
||||
ans = 0
|
||||
for hist_len in range(args.no_backoff_ngram_order, args.ngram_order):
|
||||
# note: hist_len + 1 is the actual order.
|
||||
ans += self.GetNumNgrams(hist_len)
|
||||
return ans
|
||||
|
||||
def GetNumNgrams(self, hist_len=None):
|
||||
ans = 0
|
||||
if hist_len is None:
|
||||
for hist_len in range(args.ngram_order):
|
||||
# note: hist_len + 1 is the actual order.
|
||||
ans += self.GetNumNgrams(hist_len)
|
||||
return ans
|
||||
else:
|
||||
for counts_for_hist in self.counts[hist_len].values():
|
||||
ans += len(counts_for_hist.word_to_count)
|
||||
if self.backoff_symbol in counts_for_hist.word_to_count:
|
||||
ans -= 1 # don't count the backoff symbol, it doesn't produce
|
||||
# its own n-gram line.
|
||||
return ans
|
||||
|
||||
# this function, used in PrintAsArpa, converts an integer to
|
||||
# a string by either printing it as a string, or for self.bos_symbol
|
||||
# and self.eos_symbol, printing them as "<s>" and "</s>" respectively.
|
||||
def IntToString(self, i):
|
||||
if i == self.bos_symbol:
|
||||
return "<s>"
|
||||
elif i == self.eos_symbol:
|
||||
return "</s>"
|
||||
else:
|
||||
assert i != self.backoff_symbol
|
||||
return str(i)
|
||||
|
||||
def PrintAsArpa(self):
|
||||
# Prints out the FST in ARPA format.
|
||||
assert args.no_backoff_ngram_order == 1 # without unigrams we couldn't
|
||||
# print as ARPA format.
|
||||
|
||||
print("\\data\\")
|
||||
for hist_len in range(args.ngram_order):
|
||||
# print the number of n-grams. Add 1 for the 1-gram
|
||||
# section because of <s>, we print -99 as the prob so we
|
||||
# have a place to put the backoff prob.
|
||||
print(
|
||||
"ngram {0}={1}".format(
|
||||
hist_len + 1,
|
||||
self.GetNumNgrams(hist_len) + (1 if hist_len == 0 else 0),
|
||||
)
|
||||
)
|
||||
|
||||
print("")
|
||||
|
||||
for hist_len in range(args.ngram_order):
|
||||
print("\\{0}-grams:".format(hist_len + 1))
|
||||
|
||||
# print fake n-gram for <s>, for its backoff prob.
|
||||
if hist_len == 0:
|
||||
backoff_prob = self.GetProb((self.bos_symbol,), self.backoff_symbol)
|
||||
if backoff_prob != None:
|
||||
print("-99\t<s>\t{0}".format("%.5f" % math.log10(backoff_prob)))
|
||||
|
||||
for hist in self.counts[hist_len].keys():
|
||||
for word in self.counts[hist_len][hist].word_to_count.keys():
|
||||
if word != self.backoff_symbol:
|
||||
prob = self.GetProb(hist, word)
|
||||
assert prob != None and prob > 0
|
||||
backoff_prob = self.GetProb((hist) + (word,), self.backoff_symbol)
|
||||
line = "{0}\t{1}".format(
|
||||
"%.5f" % math.log10(prob),
|
||||
" ".join(self.IntToString(x) for x in hist + (word,)),
|
||||
)
|
||||
if backoff_prob != None:
|
||||
line += "\t{0}".format("%.5f" % math.log10(backoff_prob))
|
||||
print(line)
|
||||
print("")
|
||||
print("\\end\\")
|
||||
|
||||
|
||||
ngram_counts = NgramCounts(args.ngram_order)
|
||||
ngram_counts.AddRawCountsFromStandardInput()
|
||||
|
||||
if args.verbose >= 3:
|
||||
ngram_counts.Print("Raw counts:")
|
||||
ngram_counts.ApplyBackoff()
|
||||
if args.verbose >= 3:
|
||||
ngram_counts.Print("Counts after applying Kneser-Ney discounting:")
|
||||
ngram_counts.EnsureStructurallyNeededNgramsExist()
|
||||
if args.verbose >= 3:
|
||||
ngram_counts.Print("Counts after adding structurally-needed n-grams (1st time):")
|
||||
ngram_counts.PruneEmptyStates()
|
||||
if args.verbose >= 3:
|
||||
ngram_counts.Print("Counts after removing empty states:")
|
||||
ngram_counts.PruneToFinalTarget(args.num_extra_ngrams)
|
||||
|
||||
ngram_counts.EnsureStructurallyNeededNgramsExist()
|
||||
if args.verbose >= 3:
|
||||
ngram_counts.Print("Counts after adding structurally-needed n-grams (2nd time):")
|
||||
|
||||
|
||||
if args.print_as_arpa == "true":
|
||||
ngram_counts.PrintAsArpa()
|
||||
else:
|
||||
if args.phone_disambig_symbol is None:
|
||||
sys.exit("make_phone_lm.py: --phone-disambig-symbol must be provided (unless " "you are writing as ARPA")
|
||||
ngram_counts.PrintAsFst(args.phone_disambig_symbol)
|
||||
|
||||
|
||||
## Below are some little test commands that can be used to look at the detailed stats
|
||||
## for a kind of sanity check.
|
||||
# test comand:
|
||||
# (echo 6 7 8 4; echo 7 8 9; echo 7 8; echo 7 4; echo 8 4 ) | utils/lang/make_phone_lm.py --phone-disambig-symbol=400 --verbose=3
|
||||
# (echo 6 7 8 4; echo 7 8 9; echo 7 8; echo 7 4; echo 8 4 ) | utils/lang/make_phone_lm.py --phone-disambig-symbol=400 --verbose=3 --num-extra-ngrams=0
|
||||
# (echo 6 7 8 4; echo 6 7 ) | utils/lang/make_phone_lm.py --print-as-arpa=true --no-backoff-ngram-order=1 --verbose=3
|
||||
|
||||
|
||||
## The following shows how we created some data suitable to do comparisons with
|
||||
## other language modeling toolkits. Note: we're running in a configuration
|
||||
## where --no-backoff-ngram-order=1 (i.e. we have a unigram LM state) because
|
||||
## it's the only way to get perplexity calculations and to write an ARPA file.
|
||||
##
|
||||
# cd egs/tedlium/s5_r2
|
||||
# . ./path.sh
|
||||
# mkdir -p lm_test
|
||||
# ali-to-phones exp/tri3/final.mdl "ark:gunzip -c exp/tri3/ali.*.gz|" ark,t:- | awk '{$1 = ""; print}' > lm_test/phone_seqs
|
||||
# wc lm_test/phone_seqs
|
||||
# 92464 8409563 27953288 lm_test/phone_seqs
|
||||
# head -n 20000 lm_test/phone_seqs > lm_test/train.txt
|
||||
# tail -n 1000 lm_test/phone_seqs > lm_test/test.txt
|
||||
|
||||
## This shows make_phone_lm.py with the default number of extra-lm-states (20k)
|
||||
## You have to have SRILM on your path to ger perplexities [note: it should be on the
|
||||
## path if you installed it and you sourced the tedlium s5b path.sh, as above.]
|
||||
# utils/lang/make_phone_lm.py --print-as-arpa=true --no-backoff-ngram-order=1 --verbose=1 < lm_test/train.txt > lm_test/arpa_pr20k
|
||||
# ngram -order 4 -unk -lm lm_test/arpa_pr20k -ppl lm_test/test.txt
|
||||
# file lm_test/test.txt: 1000 sentences, 86489 words, 3 OOVs
|
||||
# 0 zeroprobs, logprob= -80130.1 ppl=*8.23985* ppl1= 8.44325
|
||||
# on training data: 0 zeroprobs, logprob= -1.6264e+06 ppl= 7.46947 ppl1= 7.63431
|
||||
|
||||
## This shows make_phone_lm.py without any pruning (make --num-extra-ngrams very large).
|
||||
# utils/lang/make_phone_lm.py --print-as-arpa=true --num-extra-ngrams=1000000 --no-backoff-ngram-order=1 --verbose=1 < lm_test/train.txt > lm_test/arpa
|
||||
# ngram -order 4 -unk -lm lm_test/arpa -ppl lm_test/test.txt
|
||||
# file lm_test/test.txt: 1000 sentences, 86489 words, 3 OOVs
|
||||
# 0 zeroprobs, logprob= -74976 ppl=*7.19459* ppl1= 7.36064
|
||||
# on training data: 0 zeroprobs, logprob= -1.44198e+06 ppl= 5.94659 ppl1= 6.06279
|
||||
|
||||
## This is SRILM without pruning (c.f. the 7.19 above, it's slightly better).
|
||||
# ngram-count -text lm_test/train.txt -order 4 -kndiscount2 -kndiscount3 -kndiscount4 -interpolate -lm lm_test/arpa_srilm
|
||||
# ngram -order 4 -unk -lm lm_test/arpa_srilm -ppl lm_test/test.txt
|
||||
# file lm_test/test.txt: 1000 sentences, 86489 words, 3 OOVs
|
||||
# 0 zeroprobs, logprob= -74742.2 ppl= *7.15044* ppl1= 7.31494
|
||||
|
||||
|
||||
## This is SRILM with a pruning beam tuned to get 20k n-grams above unigram
|
||||
## (c.f. the 8.23 above, it's a lot worse).
|
||||
# ngram-count -text lm_test/train.txt -order 4 -kndiscount2 -kndiscount3 -kndiscount4 -interpolate -prune 1.65e-05 -lm lm_test/arpa_srilm.pr1.65e-5
|
||||
# the model has 20249 n-grams above unigram [c.f. our 20k]
|
||||
# ngram -order 4 -unk -lm lm_test/arpa_srilm.pr1.65e-5 -ppl lm_test/test.txt
|
||||
# file lm_test/test.txt: 1000 sentences, 86489 words, 3 OOVs
|
||||
# 0 zeroprobs, logprob= -86803.7 ppl=*9.82202* ppl1= 10.0849
|
||||
|
||||
|
||||
## This is pocolm..
|
||||
## Note: we have to hold out some of the training data as dev to
|
||||
## estimate the hyperparameters, but we'll fold it back in before
|
||||
## making the final LM. [--fold-dev-into=train]
|
||||
# mkdir -p lm_test/data/text
|
||||
# head -n 1000 lm_test/train.txt > lm_test/data/text/dev.txt
|
||||
# tail -n +1001 lm_test/train.txt > lm_test/data/text/train.txt
|
||||
## give it a 'large' num-words so it picks them all.
|
||||
# export PATH=$PATH:../../../tools/pocolm/scripts
|
||||
# train_lm.py --num-word=100000 --fold-dev-into=train lm_test/data/text 4 lm_test/data/lm_unpruned
|
||||
# get_data_prob.py lm_test/test.txt lm_test/data/lm_unpruned/100000_4.pocolm
|
||||
## compute-probs: average log-prob per word was -1.95956 (perplexity = *7.0962*) over 87489 words.
|
||||
## Note: we can compare this perplexity with 7.15 with SRILM and 7.19 with make_phone_lm.py.
|
||||
|
||||
# pruned_lm_dir=${lm_dir}/${num_word}_${order}_prune${threshold}.pocolm
|
||||
# prune_lm_dir.py --target-num-ngrams=20100 lm_test/data/lm_unpruned/100000_4.pocolm lm_test/data/lm_unpruned/100000_4_pr20k.pocolm
|
||||
# get_data_prob.py lm_test/test.txt lm_test/data/lm_unpruned/100000_4_pr20k.pocolm
|
||||
## compute-probs: average log-prob per word was -2.0409 (perplexity = 7.69757) over 87489 words.
|
||||
## note: the 7.69 can be compared with 9.82 from SRILM and 8.23 from pocolm.
|
||||
## format_arpa_lm.py lm_test/data/lm_unpruned/100000_4_pr20k.pocolm | head
|
||||
## .. it has 20488 n-grams above unigram. More than 20k but not enough to explain the difference
|
||||
## .. in perplexity.
|
||||
|
||||
## OK... if I reran after modifying prune_lm_dir.py to comment out the line
|
||||
## 'steps += 'EM EM'.split()' which adds the two EM stages per step, and got the
|
||||
## perplexity again, I got the following:
|
||||
## compute-probs: average log-prob per word was -2.09722 (perplexity = 8.14353) over 87489 words.
|
||||
## .. so it turns out the E-M is actually important.
|
||||
@@ -0,0 +1,485 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""
|
||||
This script would interpolate two arpa N-gram language models (LMs),
|
||||
culculate perplexity of resulted LM, and make binary KenLM from it.
|
||||
|
||||
Minimun usage example to interpolate two N-gram language models with weights:
|
||||
alpha * ngram_a + beta * ngram_b = 2 * ngram_a + 1 * ngram_b
|
||||
|
||||
python3 ngram_merge.py --kenlm_bin_path /workspace/nemo/decoders/kenlm/build/bin \
|
||||
--arpa_a /path/ngram_a.kenlm.tmp.arpa \
|
||||
--alpha 2 \
|
||||
--arpa_b /path/ngram_b.kenlm.tmp.arpa \
|
||||
--beta 1 \
|
||||
--out_path /path/out
|
||||
|
||||
|
||||
Merge two N-gram language models and calculate its perplexity with test_file.
|
||||
python3 ngram_merge.py --kenlm_bin_path /workspace/nemo/decoders/kenlm/build/bin \
|
||||
--ngram_bin_path /workspace/nemo/decoders/ngram-1.3.14/src/bin \
|
||||
--arpa_a /path/ngram_a.kenlm.tmp.arpa \
|
||||
--alpha 0.5 \
|
||||
--arpa_b /path/ngram_b.kenlm.tmp.arpa \
|
||||
--beta 0.5 \
|
||||
--out_path /path/out \
|
||||
--nemo_model_file /path/to/model_tokenizer.nemo \
|
||||
--test_file /path/to/test_manifest.json \
|
||||
--force
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Tuple
|
||||
|
||||
import torch
|
||||
|
||||
import nemo.collections.asr as nemo_asr
|
||||
from nemo.collections.asr.modules.rnnt import RNNTDecoder
|
||||
from nemo.collections.asr.parts.submodules.ngram_lm import DEFAULT_TOKEN_OFFSET, kenlm_utils
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
class NgramMerge:
|
||||
def __init__(self, ngram_bin_path):
|
||||
self.ngram_bin_path = ngram_bin_path
|
||||
|
||||
def ngrammerge(self, arpa_a: str, alpha: float, arpa_b: str, beta: float, arpa_c: str, force: bool) -> str:
|
||||
"""
|
||||
Merge two ARPA n-gram language models using the ngrammerge command-line tool and output the result in ARPA format.
|
||||
|
||||
Args:
|
||||
arpa_a (str): Path to the first input ARPA file.
|
||||
alpha (float): Interpolation weight for the first model.
|
||||
arpa_b (str): Path to the second input ARPA file.
|
||||
beta (float): Interpolation weight for the second model.
|
||||
arpa_c (str): Path to the output ARPA file.
|
||||
force (bool): Whether to overwrite existing output files.
|
||||
|
||||
Returns:
|
||||
str: Path to the output ARPA file in mod format.
|
||||
"""
|
||||
mod_a = arpa_a + ".mod"
|
||||
mod_b = arpa_b + ".mod"
|
||||
mod_c = arpa_c + ".mod"
|
||||
if os.path.isfile(mod_c) and not force:
|
||||
logging.info("File " + mod_c + " exists. Skipping.")
|
||||
else:
|
||||
sh_args = [
|
||||
os.path.join(self.ngram_bin_path, "ngrammerge"),
|
||||
"--alpha=" + str(alpha),
|
||||
"--beta=" + str(beta),
|
||||
"--normalize",
|
||||
# "--use_smoothing",
|
||||
mod_a,
|
||||
mod_b,
|
||||
mod_c,
|
||||
]
|
||||
logging.info(
|
||||
"\n"
|
||||
+ str(
|
||||
subprocess.run(
|
||||
sh_args,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
)
|
||||
+ "\n",
|
||||
)
|
||||
return mod_c
|
||||
|
||||
def arpa2mod(self, arpa_path: str, force: bool):
|
||||
"""
|
||||
This function reads an ARPA n-gram model and converts it to a binary format. The binary model is saved to the same directory as the ARPA model with a ".mod" extension. If the binary model file already exists and force argument is False, then the function skips conversion and returns a message. Otherwise, it executes the command to create a binary model using the subprocess.run method.
|
||||
|
||||
Parameters:
|
||||
arpa_path (string): The file path to the ARPA n-gram model.
|
||||
force (bool): If True, the function will convert the ARPA model to binary even if the binary file already exists. If False and the binary file exists, the function will skip the conversion.
|
||||
Returns:
|
||||
If the binary model file already exists and force argument is False, returns a message indicating that the file exists and the conversion is skipped.
|
||||
Otherwise, returns a subprocess.CompletedProcess object, which contains information about the executed command. The subprocess's output and error streams are redirected to stdout and stderr, respectively.
|
||||
"""
|
||||
mod_path = arpa_path + ".mod"
|
||||
if os.path.isfile(mod_path) and not force:
|
||||
return "File " + mod_path + " exists. Skipping."
|
||||
else:
|
||||
sh_args = [
|
||||
os.path.join(self.ngram_bin_path, "ngramread"),
|
||||
"--ARPA",
|
||||
arpa_path,
|
||||
mod_path,
|
||||
]
|
||||
return subprocess.run(
|
||||
sh_args,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
|
||||
def merge(
|
||||
self, arpa_a: str, alpha: float, arpa_b: str, beta: float, out_path: str, force: bool
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Merges two ARPA language models using the ngrammerge tool.
|
||||
|
||||
Args:
|
||||
arpa_a (str): Path to the first ARPA language model file.
|
||||
alpha (float): Interpolation weight for the first model.
|
||||
arpa_b (str): Path to the second ARPA language model file.
|
||||
beta (float): Interpolation weight for the second model.
|
||||
out_path (str): Path to the output directory for the merged ARPA model.
|
||||
force (bool): Whether to force overwrite of existing files.
|
||||
|
||||
Returns:
|
||||
Tuple[str, str]: A tuple containing the path to the merged binary language model file and the path to the
|
||||
merged ARPA language model file.
|
||||
"""
|
||||
logging.info("\n" + str(self.arpa2mod(arpa_a, force)) + "\n")
|
||||
|
||||
logging.info("\n" + str(self.arpa2mod(arpa_b, force)) + "\n")
|
||||
arpa_c = os.path.join(
|
||||
out_path,
|
||||
f"{os.path.split(arpa_a)[1]}-{alpha}-{os.path.split(arpa_b)[1]}-{beta}.arpa",
|
||||
)
|
||||
mod_c = self.ngrammerge(arpa_a, alpha, arpa_b, beta, arpa_c, force)
|
||||
return mod_c, arpa_c
|
||||
|
||||
def perplexity(self, ngram_mod: str, test_far: str) -> str:
|
||||
"""
|
||||
Calculates perplexity of a given ngram model on a test file.
|
||||
|
||||
Args:
|
||||
ngram_mod (str): The path to the ngram model file.
|
||||
test_far (str): The path to the test file.
|
||||
|
||||
Returns:
|
||||
str: A string representation of the perplexity calculated.
|
||||
|
||||
Raises:
|
||||
AssertionError: If the subprocess to calculate perplexity returns a non-zero exit code.
|
||||
|
||||
Example:
|
||||
>>> perplexity("/path/to/ngram_model", "/path/to/test_file")
|
||||
'Perplexity: 123.45'
|
||||
"""
|
||||
sh_args = [
|
||||
os.path.join(self.ngram_bin_path, "ngramperplexity"),
|
||||
"--v=1",
|
||||
ngram_mod,
|
||||
test_far,
|
||||
]
|
||||
ps = subprocess.Popen(sh_args, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
stdout, stderr = ps.communicate()
|
||||
exit_code = ps.wait()
|
||||
command = " ".join(sh_args)
|
||||
assert (
|
||||
exit_code == 0
|
||||
), f"Exit_code must be 0.\n bash command: {command} \n stdout: {stdout} \n stderr: {stderr}"
|
||||
perplexity_out = "\n".join(stdout.split("\n")[-6:-1])
|
||||
return perplexity_out
|
||||
|
||||
def make_arpa(self, ngram_mod: str, ngram_arpa: str, force: bool):
|
||||
"""
|
||||
Converts an ngram model in binary format to ARPA format.
|
||||
|
||||
Args:
|
||||
- ngram_mod (str): The path to the ngram model in binary format.
|
||||
- ngram_arpa (str): The desired path for the ARPA format output file.
|
||||
- force (bool): If True, the ARPA format file will be generated even if it already exists.
|
||||
|
||||
Returns:
|
||||
- Tuple[bytes, bytes]
|
||||
|
||||
Raises:
|
||||
- AssertionError: If the shell command execution returns a non-zero exit code.
|
||||
- FileNotFoundError: If the binary ngram model file does not exist.
|
||||
"""
|
||||
if os.path.isfile(ngram_arpa) and not force:
|
||||
logging.info("File " + ngram_arpa + " exists. Skipping.")
|
||||
return None
|
||||
else:
|
||||
sh_args = [
|
||||
os.path.join(self.ngram_bin_path, "ngramprint"),
|
||||
"--ARPA",
|
||||
ngram_mod,
|
||||
ngram_arpa,
|
||||
]
|
||||
return subprocess.run(
|
||||
sh_args,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
|
||||
def test_perplexity(self, mod_c: str, symbols: str, test_txt: str, nemo_model_file: str, tmp_path: str) -> str:
|
||||
"""
|
||||
Tests the perplexity of a given ngram model on a test file.
|
||||
|
||||
Args:
|
||||
mod_c (str): The path to the ngram model file.
|
||||
symbols (str): The path to the symbol table file.
|
||||
test_txt (str): The path to the test text file.
|
||||
nemo_model_file (str): The path to the NeMo model file.
|
||||
tmp_path (str): The path to the temporary directory where the test far file will be created.
|
||||
force (bool): If True, overwrites any existing far file.
|
||||
|
||||
Returns:
|
||||
str: A string representation of the perplexity calculated.
|
||||
|
||||
Example:
|
||||
>>> test_perplexity("/path/to/ngram_model", "/path/to/symbol_table", "/path/to/test_file", "/path/to/tokenizer_model", "/path/to/tmp_dir", True)
|
||||
'Perplexity: 123.45'
|
||||
"""
|
||||
|
||||
test_far = farcompile(symbols, test_txt, tmp_path, nemo_model_file)
|
||||
res_p = self.perplexity(mod_c, test_far)
|
||||
return res_p
|
||||
|
||||
|
||||
def farcompile(symbols: str, text_file: str, tmp_path: str, nemo_model_file: str) -> str:
|
||||
"""
|
||||
Compiles a text file into a FAR file using the given symbol table or tokenizer.
|
||||
|
||||
Args:
|
||||
symbols (str): The path to the symbol table file.
|
||||
text_file (str): The path to the text file to compile.
|
||||
tmp_path (str): The path to the temporary directory where the test far file will be created.
|
||||
nemo_model_file (str): The path to the NeMo model file (.nemo).
|
||||
force (bool): If True, overwrites any existing FAR file.
|
||||
|
||||
Returns:
|
||||
test_far (str): The path to the resulting FAR file.
|
||||
|
||||
Example:
|
||||
>>> farcompile("/path/to/symbol_table", "/path/to/text_file", "/path/to/far_file", "/path/to/tokenizer_model", "/path/to/nemo_model", True)
|
||||
"""
|
||||
test_far = os.path.join(tmp_path, os.path.split(text_file)[1] + ".far")
|
||||
|
||||
sh_args = [
|
||||
"farcompilestrings",
|
||||
"--generate_keys=10",
|
||||
"--fst_type=compact",
|
||||
"--symbols=" + symbols,
|
||||
"--keep_symbols",
|
||||
]
|
||||
|
||||
tokenizer, encoding_level, is_aggregate_tokenizer, _ = kenlm_utils.setup_tokenizer(nemo_model_file)
|
||||
|
||||
with open(test_far, "wb") as test_far_stdout:
|
||||
ps = subprocess.Popen(
|
||||
sh_args,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=test_far_stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
|
||||
kenlm_utils.iter_files(
|
||||
source_path=[text_file],
|
||||
dest_path=ps.stdin,
|
||||
tokenizer=tokenizer,
|
||||
encoding_level=encoding_level,
|
||||
is_aggregate_tokenizer=is_aggregate_tokenizer,
|
||||
verbose=1,
|
||||
)
|
||||
stdout, stderr = ps.communicate()
|
||||
|
||||
exit_code = ps.returncode
|
||||
|
||||
command = f"{shlex.join(sh_args)} > {shlex.quote(test_far)}"
|
||||
assert exit_code == 0, f"Exit_code must be 0.\n bash command: {command} \n stdout: {stdout} \n stderr: {stderr}"
|
||||
return test_far
|
||||
|
||||
|
||||
def make_kenlm(kenlm_bin_path: str, ngram_arpa: str, force: bool):
|
||||
"""
|
||||
Builds a language model from an ARPA format file using the KenLM toolkit.
|
||||
|
||||
Args:
|
||||
- kenlm_bin_path (str): The path to the KenLM toolkit binary.
|
||||
- ngram_arpa (str): The path to the ARPA format file.
|
||||
- force (bool): If True, the KenLM language model will be generated even if it already exists.
|
||||
|
||||
Raises:
|
||||
- AssertionError: If the shell command execution returns a non-zero exit code.
|
||||
- FileNotFoundError: If the KenLM binary or ARPA format file does not exist.
|
||||
"""
|
||||
ngram_kenlm = ngram_arpa + ".kenlm"
|
||||
if os.path.isfile(ngram_kenlm) and not force:
|
||||
logging.info("File " + ngram_kenlm + " exists. Skipping.")
|
||||
return None
|
||||
else:
|
||||
sh_args = [os.path.join(kenlm_bin_path, "build_binary"), "trie", "-i", ngram_arpa, ngram_kenlm]
|
||||
return subprocess.run(
|
||||
sh_args,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def make_symbol_list(nemo_model_file, symbols, force):
|
||||
"""
|
||||
Function: make_symbol_list
|
||||
|
||||
Create a symbol table for the input tokenizer model file.
|
||||
|
||||
Args:
|
||||
nemo_model_file (str): Path to the NeMo model file.
|
||||
symbols (str): Path to the file where symbol list will be saved.
|
||||
force (bool): Flag to force creation of symbol list even if it already exists.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
None
|
||||
"""
|
||||
if os.path.isfile(symbols) and not force:
|
||||
logging.info("File " + symbols + " exists. Skipping.")
|
||||
else:
|
||||
if nemo_model_file.endswith('.nemo'):
|
||||
asr_model = nemo_asr.models.ASRModel.restore_from(nemo_model_file, map_location=torch.device('cpu'))
|
||||
else:
|
||||
logging.warning(
|
||||
"nemo_model_file does not end with .nemo, therefore trying to load a pretrained model with this name."
|
||||
)
|
||||
asr_model = nemo_asr.models.ASRModel.from_pretrained(nemo_model_file, map_location=torch.device('cpu'))
|
||||
|
||||
if isinstance(asr_model.decoder, RNNTDecoder):
|
||||
vocab_size = asr_model.decoder.blank_idx
|
||||
else:
|
||||
vocab_size = len(asr_model.decoder.vocabulary)
|
||||
|
||||
vocab = [chr(idx + DEFAULT_TOKEN_OFFSET) for idx in range(vocab_size)]
|
||||
with open(symbols, "w", encoding="utf-8") as f:
|
||||
for i, v in enumerate(vocab):
|
||||
f.write(v + " " + str(i) + "\n")
|
||||
|
||||
|
||||
def main(
|
||||
kenlm_bin_path: str,
|
||||
ngram_bin_path: str,
|
||||
arpa_a: str,
|
||||
alpha: float,
|
||||
arpa_b: str,
|
||||
beta: float,
|
||||
out_path: str,
|
||||
test_file: str,
|
||||
symbols: str,
|
||||
nemo_model_file: str,
|
||||
force: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Entry point function for merging ARPA format language models, testing perplexity, creating symbol list,
|
||||
and making ARPA and Kenlm models.
|
||||
|
||||
Args:
|
||||
- kenlm_bin_path (str): The path to the Kenlm binary.
|
||||
- arpa_a (str): The path to the first ARPA format language model.
|
||||
- alpha (float): The weight given to the first language model during merging.
|
||||
- arpa_b (str): The path to the second ARPA format language model.
|
||||
- beta (float): The weight given to the second language model during merging.
|
||||
- out_path (str): The path where the output files will be saved.
|
||||
- test_file (str): The path to the file on which perplexity needs to be calculated.
|
||||
- symbols (str): The path to the file where symbol list for the tokenizer model will be saved.
|
||||
- nemo_model_file (str): The path to the NeMo model file.
|
||||
- force (bool): If True, overwrite existing files, otherwise skip the operations.
|
||||
|
||||
Returns:
|
||||
- None
|
||||
"""
|
||||
nm = NgramMerge(ngram_bin_path)
|
||||
mod_c, arpa_c = nm.merge(arpa_a, alpha, arpa_b, beta, out_path, force)
|
||||
|
||||
if test_file and nemo_model_file:
|
||||
if not symbols:
|
||||
symbols = os.path.join(out_path, os.path.split(nemo_model_file)[1] + ".syms")
|
||||
make_symbol_list(nemo_model_file, symbols, force)
|
||||
for test_f in test_file.split(","):
|
||||
test_p = nm.test_perplexity(mod_c, symbols, test_f, nemo_model_file, out_path)
|
||||
logging.info("Perplexity summary " + test_f + " : " + test_p)
|
||||
|
||||
logging.info("Making ARPA and Kenlm model " + arpa_c)
|
||||
out = nm.make_arpa(mod_c, arpa_c, force)
|
||||
if out:
|
||||
logging.info("\n" + str(out) + "\n")
|
||||
|
||||
out = make_kenlm(kenlm_bin_path, arpa_c, force)
|
||||
if out:
|
||||
logging.info("\n" + str(out) + "\n")
|
||||
|
||||
|
||||
def _parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Interpolate ARPA N-gram language models and make KenLM binary model to be used with beam search decoder of ASR models."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--kenlm_bin_path",
|
||||
required=True,
|
||||
type=str,
|
||||
help="The path to the bin folder of KenLM library.",
|
||||
) # Use /workspace/nemo/decoders/kenlm/build/bin if installed it with scripts/asr_language_modeling/ngram_lm/install_beamsearch_decoders.sh
|
||||
parser.add_argument(
|
||||
"--ngram_bin_path",
|
||||
required=True,
|
||||
type=str,
|
||||
help="The path to the bin folder of OpenGrm Ngram library.",
|
||||
) # Use /workspace/nemo/decoders/ngram-1.3.14/src/bin if installed it with scripts/installers/install_opengrm.sh
|
||||
parser.add_argument("--arpa_a", required=True, type=str, help="Path to the arpa_a")
|
||||
parser.add_argument("--alpha", required=True, type=float, help="Weight of arpa_a")
|
||||
parser.add_argument("--arpa_b", required=True, type=str, help="Path to the arpa_b")
|
||||
parser.add_argument("--beta", required=True, type=float, help="Weight of arpa_b")
|
||||
parser.add_argument(
|
||||
"--out_path",
|
||||
required=True,
|
||||
type=str,
|
||||
help="Path to write tmp and resulted files.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test_file",
|
||||
required=False,
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to test file to count perplexity if provided.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--symbols",
|
||||
required=False,
|
||||
type=str,
|
||||
default=None,
|
||||
help="Path to symbols (.syms) file . Could be calculated if it is not provided. Use as: --symbols /path/to/earnest.syms",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--nemo_model_file",
|
||||
required=False,
|
||||
type=str,
|
||||
default=None,
|
||||
help="The path to '.nemo' file of the ASR model, or name of a pretrained NeMo model",
|
||||
)
|
||||
parser.add_argument("--force", "-f", action="store_true", help="Whether to recompile and rewrite all files")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(**vars(_parse_args()))
|
||||
@@ -0,0 +1,196 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
|
||||
# This script would train an N-gram language model with KenLM library (https://github.com/kpu/kenlm) which can be used
|
||||
# with the beam search decoders on top of the ASR models. This script supports both character level and BPE level
|
||||
# encodings and models which is detected automatically from the type of the model.
|
||||
# After the N-gram model is trained, and stored in the binary format, you may use
|
||||
# 'scripts/ngram_lm/eval_beamsearch_ngram.py' to evaluate it on an ASR model.
|
||||
#
|
||||
# You need to install the KenLM library and also the beam search decoders to use this feature. Please refer
|
||||
# to 'scripts/ngram_lm/install_beamsearch_decoders.sh' on how to install them.
|
||||
#
|
||||
# USAGE: python train_kenlm.py nemo_model_file=<path to the .nemo file of the model> \
|
||||
# train_paths=<list of paths to the training text or JSON manifest file> \
|
||||
# kenlm_bin_path=<path to the bin folder of KenLM library> \
|
||||
# kenlm_model_file=<path to store the binary KenLM model> \
|
||||
# ngram_length=<order of N-gram model> \
|
||||
#
|
||||
# After training is done, the binary LM model is stored at the path specified by '--kenlm_model_file'.
|
||||
# You may find more info on how to use this script at:
|
||||
# https://docs.nvidia.com/deeplearning/nemo/user-guide/docs/en/main/asr/asr_language_modeling.html
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from glob import glob
|
||||
from typing import List
|
||||
|
||||
from omegaconf import MISSING
|
||||
|
||||
from nemo.collections.asr.parts.submodules.ngram_lm import NGramGPULanguageModel, kenlm_utils
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging
|
||||
|
||||
"""
|
||||
NeMo's beam search decoders only support char-level encodings. In order to make it work with BPE-level encodings, we
|
||||
use a trick to encode the sub-word tokens of the training data as unicode characters and train a char-level KenLM.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrainKenlmConfig:
|
||||
"""
|
||||
Train an N-gram language model with KenLM to be used with beam search decoder of ASR models.
|
||||
"""
|
||||
|
||||
train_paths: List[str] = (
|
||||
MISSING # List of training files or folders. Files can be a plain text file or ".json" manifest or ".json.gz". Example: [/path/to/manifest/file,/path/to/folder]
|
||||
)
|
||||
|
||||
nemo_model_file: str = MISSING # The path to '.nemo' file of the ASR model, or name of a pretrained NeMo model
|
||||
kenlm_model_file: str = MISSING # The path to store the KenLM binary model file
|
||||
ngram_length: int = MISSING # The order of N-gram LM
|
||||
kenlm_bin_path: str = MISSING # The path to the bin folder of KenLM.
|
||||
|
||||
preserve_arpa: bool = False # Whether to preserve the intermediate ARPA file.
|
||||
ngram_prune: List[int] = field(
|
||||
default_factory=lambda: [0]
|
||||
) # List of digits to prune Ngram. Example: [0,0,1]. See Pruning section on the https://kheafield.com/code/kenlm/estimation
|
||||
cache_path: str = "" # Cache path to save tokenized files.
|
||||
verbose: int = 1 # Verbose level, default is 1.
|
||||
save_nemo: bool = False # Save .nemo checkpoint to use with NGramGPULanguageModel
|
||||
normalize_unk_nemo: bool = True # Normalize the UNK token in the NGramGPULanguageModel model
|
||||
|
||||
|
||||
@hydra_runner(config_path=None, config_name='TrainKenlmConfig', schema=TrainKenlmConfig)
|
||||
def main(args: TrainKenlmConfig):
|
||||
train_paths = kenlm_utils.get_train_list(args.train_paths)
|
||||
|
||||
if isinstance(args.ngram_prune, str):
|
||||
args.ngram_prune = [args.ngram_prune]
|
||||
|
||||
tokenizer, encoding_level, is_aggregate_tokenizer, full_vocab_size = kenlm_utils.setup_tokenizer(
|
||||
args.nemo_model_file
|
||||
)
|
||||
|
||||
if encoding_level == "subword":
|
||||
discount_arg = "--discount_fallback" # --discount_fallback is needed for training KenLM for BPE-based models
|
||||
else:
|
||||
discount_arg = ""
|
||||
|
||||
arpa_file = f"{args.kenlm_model_file}.tmp.arpa"
|
||||
""" LMPLZ ARGUMENT SETUP """
|
||||
kenlm_args = [
|
||||
os.path.join(args.kenlm_bin_path, 'lmplz'),
|
||||
"-o",
|
||||
str(args.ngram_length),
|
||||
"--arpa",
|
||||
arpa_file,
|
||||
discount_arg,
|
||||
"--prune",
|
||||
] + [str(n) for n in args.ngram_prune]
|
||||
|
||||
if args.cache_path:
|
||||
if not os.path.exists(args.cache_path):
|
||||
os.makedirs(args.cache_path, exist_ok=True)
|
||||
|
||||
""" DATASET SETUP """
|
||||
encoded_train_files = []
|
||||
for file_num, train_file in enumerate(train_paths):
|
||||
logging.info(f"Encoding the train file '{train_file}' number {file_num+1} out of {len(train_paths)} ...")
|
||||
|
||||
cached_files = glob(os.path.join(args.cache_path, os.path.split(train_file)[1]) + "*")
|
||||
encoded_train_file = os.path.join(args.cache_path, os.path.split(train_file)[1] + f"_{file_num}.tmp.txt")
|
||||
if (
|
||||
cached_files and cached_files[0] != encoded_train_file
|
||||
): # cached_files exists but has another file name: f"_{file_num}.tmp.txt"
|
||||
os.rename(cached_files[0], encoded_train_file)
|
||||
logging.info("Rename", cached_files[0], "to", encoded_train_file)
|
||||
|
||||
encoded_train_files.append(encoded_train_file)
|
||||
|
||||
kenlm_utils.iter_files(
|
||||
source_path=train_paths,
|
||||
dest_path=encoded_train_files,
|
||||
tokenizer=tokenizer,
|
||||
encoding_level=encoding_level,
|
||||
is_aggregate_tokenizer=is_aggregate_tokenizer,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
first_process_args = ["cat"] + encoded_train_files
|
||||
first_process = subprocess.Popen(first_process_args, stdout=subprocess.PIPE, stderr=sys.stderr)
|
||||
|
||||
logging.info(f"Running lmplz command \n\n{' '.join(kenlm_args)}\n\n")
|
||||
kenlm_p = subprocess.run(
|
||||
kenlm_args,
|
||||
stdin=first_process.stdout,
|
||||
capture_output=False,
|
||||
text=True,
|
||||
stdout=sys.stdout,
|
||||
stderr=sys.stderr,
|
||||
)
|
||||
first_process.wait()
|
||||
|
||||
else:
|
||||
logging.info(f"Running lmplz command \n\n{' '.join(kenlm_args)}\n\n")
|
||||
kenlm_p = subprocess.Popen(kenlm_args, stdout=sys.stdout, stdin=subprocess.PIPE, stderr=sys.stderr)
|
||||
kenlm_utils.iter_files(
|
||||
source_path=train_paths,
|
||||
dest_path=kenlm_p.stdin,
|
||||
tokenizer=tokenizer,
|
||||
encoding_level=encoding_level,
|
||||
is_aggregate_tokenizer=is_aggregate_tokenizer,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
kenlm_p.communicate()
|
||||
|
||||
if kenlm_p.returncode != 0:
|
||||
raise RuntimeError("Training KenLM was not successful!")
|
||||
|
||||
""" BINARY BUILD """
|
||||
|
||||
kenlm_args = [
|
||||
os.path.join(args.kenlm_bin_path, "build_binary"),
|
||||
"trie",
|
||||
arpa_file,
|
||||
args.kenlm_model_file,
|
||||
]
|
||||
logging.info(f"Running binary_build command \n\n{' '.join(kenlm_args)}\n\n")
|
||||
ret = subprocess.run(kenlm_args, capture_output=False, text=True, stdout=sys.stdout, stderr=sys.stderr)
|
||||
|
||||
if ret.returncode != 0:
|
||||
raise RuntimeError("Training KenLM was not successful!")
|
||||
|
||||
if args.save_nemo:
|
||||
if full_vocab_size is None:
|
||||
raise NotImplementedError("Unknown vocab size, cannot convert the model")
|
||||
nemo_model = NGramGPULanguageModel.from_arpa(
|
||||
lm_path=arpa_file, vocab_size=full_vocab_size, normalize_unk=args.normalize_unk_nemo
|
||||
)
|
||||
nemo_model.save_to(f"{args.kenlm_model_file}.nemo")
|
||||
|
||||
if not args.preserve_arpa:
|
||||
os.remove(arpa_file)
|
||||
logging.info(f"Deleted the arpa file '{arpa_file}'.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,77 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
|
||||
from nemo.collections.audio.data.audio_to_audio_lhotse import convert_manifest_nemo_to_lhotse
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert an audio-to-audio manifest from NeMo format to Lhotse format. "
|
||||
"This step enables the use of Lhotse datasets for audio-to-audio processing. "
|
||||
)
|
||||
parser.add_argument("input", help='Path to the input NeMo manifest.')
|
||||
parser.add_argument(
|
||||
"output", help="Path where we'll write the output Lhotse manifest (supported extensions: .jsonl.gz and .jsonl)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--input_key",
|
||||
default="audio_filepath",
|
||||
help="Key of the input recording, mapped to Lhotse's 'Cut.recording'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--target_key",
|
||||
default="target_filepath",
|
||||
help="Key of the target recording, mapped to Lhotse's 'Cut.target_recording'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--reference_key",
|
||||
default="reference_filepath",
|
||||
help="Key of the reference recording, mapped to Lhotse's 'Cut.reference_recording'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-e",
|
||||
"--embedding_key",
|
||||
default="embedding_filepath",
|
||||
help="Key of the embedding, mapped to Lhotse's 'Cut.embedding_vector'.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-a",
|
||||
"--force_absolute_paths",
|
||||
action='store_true',
|
||||
default=False,
|
||||
help="Force absolute paths in the generated manifests.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
convert_manifest_nemo_to_lhotse(
|
||||
input_manifest=args.input,
|
||||
output_manifest=args.output,
|
||||
input_key=args.input_key,
|
||||
target_key=args.target_key,
|
||||
reference_key=args.reference_key,
|
||||
embedding_key=args.embedding_key,
|
||||
force_absolute_paths=args.force_absolute_paths,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,173 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Copyright 2017 Johns Hopkins University (Shinji Watanabe)
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
# Changes to script
|
||||
Change the script to import the NeMo model class you would like to load a checkpoint for,
|
||||
then update the model constructor to use this model class. This can be found by the line:
|
||||
<<< Change model class here ! >>>
|
||||
By default, this script imports and creates the `EncDecCTCModelBPE` class but it can be
|
||||
changed to any NeMo Model.
|
||||
# Run the script
|
||||
## Saving a .nemo model file (loaded with ModelPT.restore_from(...))
|
||||
HYDRA_FULL_ERROR=1 python average_model_checkpoints.py \
|
||||
--config-path="<path to config directory>" \
|
||||
--config-name="<config name>" \
|
||||
name=<name of the averaged checkpoint> \
|
||||
+checkpoint_dir=<OPTIONAL: directory of checkpoint> \
|
||||
+checkpoint_paths=\"[/path/to/ptl_1.ckpt,/path/to/ptl_2.ckpt,/path/to/ptl_3.ckpt,...]\"
|
||||
## Saving an averaged pytorch checkpoint (loaded with torch.load(...))
|
||||
HYDRA_FULL_ERROR=1 python average_model_checkpoints.py \
|
||||
--config-path="<path to config directory>" \
|
||||
--config-name="<config name>" \
|
||||
name=<name of the averaged checkpoint> \
|
||||
+checkpoint_dir=<OPTIONAL: directory of checkpoint> \
|
||||
+checkpoint_paths=\"[/path/to/ptl_1.ckpt,/path/to/ptl_2.ckpt,/path/to/ptl_3.ckpt,...]\" \
|
||||
+save_ckpt_only=true
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import lightning.pytorch as pl
|
||||
import torch
|
||||
from omegaconf import OmegaConf, open_dict
|
||||
|
||||
# Change this import to the model you would like to average
|
||||
from nemo.collections.asr.models import EncDecCTCModelBPE
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
def process_config(cfg: OmegaConf):
|
||||
"""
|
||||
Process config
|
||||
"""
|
||||
if 'name' not in cfg or cfg.name is None:
|
||||
raise ValueError("`cfg.name` must be provided to save a model checkpoint")
|
||||
|
||||
if 'checkpoint_paths' not in cfg or cfg.checkpoint_paths is None:
|
||||
raise ValueError(
|
||||
"`cfg.checkpoint_paths` must be provided as a list of one or more str paths to "
|
||||
"pytorch lightning checkpoints"
|
||||
)
|
||||
|
||||
save_ckpt_only = False
|
||||
|
||||
with open_dict(cfg):
|
||||
name_prefix = cfg.name
|
||||
checkpoint_paths = cfg.pop('checkpoint_paths')
|
||||
|
||||
if 'checkpoint_dir' in cfg:
|
||||
checkpoint_dir = cfg.pop('checkpoint_dir')
|
||||
else:
|
||||
checkpoint_dir = None
|
||||
|
||||
if 'save_ckpt_only' in cfg:
|
||||
save_ckpt_only = cfg.pop('save_ckpt_only')
|
||||
|
||||
if type(checkpoint_paths) not in (list, tuple):
|
||||
checkpoint_paths = str(checkpoint_paths).replace("[", "").replace("]", "")
|
||||
checkpoint_paths = checkpoint_paths.split(",")
|
||||
checkpoint_paths = [ckpt_path.strip() for ckpt_path in checkpoint_paths]
|
||||
|
||||
if checkpoint_dir is not None:
|
||||
checkpoint_paths = [os.path.join(checkpoint_dir, path) for path in checkpoint_paths]
|
||||
|
||||
return name_prefix, checkpoint_paths, save_ckpt_only
|
||||
|
||||
|
||||
@hydra_runner(config_path=None, config_name=None)
|
||||
def main(cfg):
|
||||
"""
|
||||
Main function
|
||||
"""
|
||||
|
||||
logging.info("This script is deprecated and will be removed in the 25.01 release.")
|
||||
|
||||
name_prefix, checkpoint_paths, save_ckpt_only = process_config(cfg)
|
||||
|
||||
if not save_ckpt_only:
|
||||
trainer = pl.Trainer(**cfg.trainer)
|
||||
|
||||
# <<< Change model class here ! >>>
|
||||
# Model architecture which will contain the averaged checkpoints
|
||||
# Change the model constructor to the one you would like (if needed)
|
||||
model = EncDecCTCModelBPE(cfg=cfg.model, trainer=trainer)
|
||||
|
||||
""" < Checkpoint Averaging Logic > """
|
||||
# load state dicts
|
||||
n = len(checkpoint_paths)
|
||||
avg_state = None
|
||||
|
||||
logging.info(f"Averaging {n} checkpoints ...")
|
||||
|
||||
for ix, path in enumerate(checkpoint_paths):
|
||||
checkpoint = torch.load(path, map_location='cpu')
|
||||
|
||||
if 'state_dict' in checkpoint:
|
||||
checkpoint = checkpoint['state_dict']
|
||||
|
||||
if ix == 0:
|
||||
# Initial state
|
||||
avg_state = checkpoint
|
||||
|
||||
logging.info(f"Initialized average state dict with checkpoint : {path}")
|
||||
else:
|
||||
# Accumulated state
|
||||
for k in avg_state:
|
||||
avg_state[k] = avg_state[k] + checkpoint[k]
|
||||
|
||||
logging.info(f"Updated average state dict with state from checkpoint : {path}")
|
||||
|
||||
for k in avg_state:
|
||||
if str(avg_state[k].dtype).startswith("torch.int"):
|
||||
# For int type, not averaged, but only accumulated.
|
||||
# e.g. BatchNorm.num_batches_tracked
|
||||
pass
|
||||
else:
|
||||
avg_state[k] = avg_state[k] / n
|
||||
|
||||
# Save model
|
||||
if save_ckpt_only:
|
||||
ckpt_name = name_prefix + '-averaged.ckpt'
|
||||
torch.save(avg_state, ckpt_name)
|
||||
|
||||
logging.info(f"Averaged pytorch checkpoint saved as : {ckpt_name}")
|
||||
else:
|
||||
# Set model state
|
||||
logging.info("Loading averaged state dict in provided model")
|
||||
model.load_state_dict(avg_state, strict=True)
|
||||
|
||||
ckpt_name = name_prefix + '-averaged.nemo'
|
||||
model.save_to(ckpt_name)
|
||||
|
||||
logging.info(f"Averaged model saved as : {ckpt_name}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""
|
||||
Builds a .nemo file with average weights over multiple .ckpt files (assumes .ckpt files in same folder as .nemo file).
|
||||
Usage example for building *-averaged.nemo for a given .nemo file:
|
||||
NeMo/scripts/checkpoint_averaging/checkpoint_averaging.py my_model.nemo
|
||||
Usage example for building *-averaged.nemo files for all results in sub-directories under current path:
|
||||
find . -name '*.nemo' | grep -v -- "-averaged.nemo" | xargs NeMo/scripts/checkpoint_averaging/checkpoint_averaging.py
|
||||
NOTE: if yout get the following error `AttributeError: Can't get attribute '???' on <module '__main__' from '???'>`
|
||||
use --import_fname_list <FILE> with all files that contains missing classes.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
import torch
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
from nemo.core import ModelPT
|
||||
from nemo.utils import logging, model_utils
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main function
|
||||
"""
|
||||
|
||||
logging.info("This script is deprecated and will be removed in the 25.01 release.")
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
'model_fname_list',
|
||||
metavar='NEMO_FILE_OR_FOLDER',
|
||||
type=str,
|
||||
nargs='+',
|
||||
help='Input .nemo files (or folders who contains them) to parse',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--import_fname_list',
|
||||
metavar='FILE',
|
||||
type=str,
|
||||
nargs='+',
|
||||
default=[],
|
||||
help='A list of Python file names to "from FILE import *"',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--class_path',
|
||||
type=str,
|
||||
default='',
|
||||
help='A path to class "module.submodule.class" (if given)',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.info(
|
||||
f"\n\nIMPORTANT:\nIf you get the following error:\n\t"
|
||||
"(AttributeError: Can't get attribute '???' on <module '__main__' from '???'>)\nuse:\n\t"
|
||||
"--import_fname_list\nfor all files that contain missing classes.\n\n"
|
||||
)
|
||||
|
||||
for fn in args.import_fname_list:
|
||||
logging.info(f"Importing * from {fn}")
|
||||
sys.path.insert(0, os.path.dirname(fn))
|
||||
globals().update(importlib.import_module(os.path.splitext(os.path.basename(fn))[0]).__dict__)
|
||||
|
||||
device = torch.device("cpu")
|
||||
|
||||
# loop over all folders with .nemo files (or .nemo files)
|
||||
for model_fname_i, model_fname in enumerate(args.model_fname_list):
|
||||
if not model_fname.endswith(".nemo"):
|
||||
# assume model_fname is a folder which contains a .nemo file
|
||||
nemo_files = list(
|
||||
filter(lambda fn: not fn.endswith("-averaged.nemo"), glob.glob(os.path.join(model_fname, "*.nemo")))
|
||||
)
|
||||
if len(nemo_files) != 1:
|
||||
raise RuntimeError(f"Expected exactly one .nemo file but discovered {len(nemo_files)} .nemo files")
|
||||
|
||||
model_fname = nemo_files[0]
|
||||
|
||||
model_folder_path = os.path.dirname(model_fname)
|
||||
fn, fe = os.path.splitext(model_fname)
|
||||
avg_model_fname = f"{fn}-averaged{fe}"
|
||||
|
||||
logging.info(f"\n===> [{model_fname_i+1} / {len(args.model_fname_list)}] Parsing folder {model_folder_path}\n")
|
||||
|
||||
# restore model from .nemo file path
|
||||
model_cfg = ModelPT.restore_from(restore_path=model_fname, return_config=True)
|
||||
if args.class_path:
|
||||
classpath = args.class_path
|
||||
else:
|
||||
classpath = model_cfg.target # original class path
|
||||
imported_class = model_utils.import_class_by_path(classpath)
|
||||
logging.info(f"Loading model {model_fname}")
|
||||
nemo_model = imported_class.restore_from(restore_path=model_fname, map_location=device)
|
||||
|
||||
# search for all checkpoints (ignore -last.ckpt)
|
||||
checkpoint_paths = [
|
||||
os.path.join(model_folder_path, x)
|
||||
for x in os.listdir(model_folder_path)
|
||||
if x.endswith('.ckpt') and not x.endswith('-last.ckpt')
|
||||
]
|
||||
""" < Checkpoint Averaging Logic > """
|
||||
# load state dicts
|
||||
n = len(checkpoint_paths)
|
||||
avg_state = None
|
||||
|
||||
logging.info(f"Averaging {n} checkpoints ...")
|
||||
|
||||
for ix, path in enumerate(tqdm(checkpoint_paths, total=n, desc='Averaging checkpoints')):
|
||||
checkpoint = torch.load(path, map_location=device)
|
||||
|
||||
if 'state_dict' in checkpoint:
|
||||
checkpoint = checkpoint['state_dict']
|
||||
else:
|
||||
raise RuntimeError(f"Checkpoint from {path} does not include a state_dict.")
|
||||
|
||||
if ix == 0:
|
||||
# Initial state
|
||||
avg_state = checkpoint
|
||||
|
||||
logging.info(f"Initialized average state dict with checkpoint:\n\t{path}")
|
||||
else:
|
||||
# Accumulated state
|
||||
for k in avg_state:
|
||||
avg_state[k] = avg_state[k] + checkpoint[k]
|
||||
|
||||
logging.info(f"Updated average state dict with state from checkpoint:\n\t{path}")
|
||||
|
||||
for k in avg_state:
|
||||
if str(avg_state[k].dtype).startswith("torch.int"):
|
||||
# For int type, not averaged, but only accumulated.
|
||||
# e.g. BatchNorm.num_batches_tracked
|
||||
pass
|
||||
else:
|
||||
avg_state[k] = avg_state[k] / n
|
||||
|
||||
# restore merged weights into model
|
||||
nemo_model.load_state_dict(avg_state, strict=True)
|
||||
# Save model
|
||||
logging.info(f"Saving average model to:\n\t{avg_model_fname}")
|
||||
nemo_model.save_to(avg_model_fname)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# USAGE: python add_noise.py --input_manifest=<manifest file of original "clean" dataset>
|
||||
# --noise_manifest=<manifest file poinitng to noise data>
|
||||
# --out_dir=<destination directory for noisy audio and manifests>
|
||||
# --snrs=<list of snrs at which noise should be added to the audio>
|
||||
# --seed=<seed for random number generator>
|
||||
# --num_workers=<number of parallel workers>
|
||||
# To be able to reproduce the same noisy dataset, use a fixed seed and num_workers=1
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import random
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
|
||||
from nemo.collections.asr.parts.preprocessing.perturb import NoisePerturbation
|
||||
from nemo.collections.asr.parts.preprocessing.segment import AudioSegment
|
||||
|
||||
rng = None
|
||||
att_factor = 0.8
|
||||
save_noise = False
|
||||
sample_rate = 16000
|
||||
|
||||
|
||||
def get_out_dir_name(out_dir, input_name, noise_name, snr):
|
||||
return os.path.join(out_dir, input_name, noise_name + "_" + str(snr) + "db")
|
||||
|
||||
|
||||
def create_manifest(input_manifest, noise_manifest, snrs, out_path, save_noise):
|
||||
os.makedirs(os.path.join(out_path, "manifests"), exist_ok=True)
|
||||
for snr in snrs:
|
||||
out_dir = get_out_dir_name(
|
||||
out_path,
|
||||
os.path.splitext(os.path.basename(input_manifest))[0],
|
||||
os.path.splitext(os.path.basename(noise_manifest))[0],
|
||||
snr,
|
||||
)
|
||||
out_mfst = os.path.join(
|
||||
os.path.join(out_path, "manifests"),
|
||||
os.path.splitext(os.path.basename(input_manifest))[0]
|
||||
+ "_"
|
||||
+ os.path.splitext(os.path.basename(noise_manifest))[0]
|
||||
+ "_"
|
||||
+ str(snr)
|
||||
+ "db"
|
||||
+ ".json",
|
||||
)
|
||||
with open(input_manifest, "r") as inf, open(out_mfst, "w") as outf:
|
||||
for line in inf:
|
||||
row = json.loads(line.strip())
|
||||
row['audio_filepath'] = os.path.join(out_dir, os.path.basename(row['audio_filepath']))
|
||||
if save_noise:
|
||||
file_ext = os.path.splitext(row['audio_filepath'])[1]
|
||||
noise_filename = os.path.basename(row['audio_filepath']).replace(file_ext, "_noise" + file_ext)
|
||||
row['noise_filepath'] = os.path.join(out_dir, noise_filename)
|
||||
outf.write(json.dumps(row) + "\n")
|
||||
|
||||
|
||||
def process_row(row):
|
||||
audio_file = row['audio_filepath']
|
||||
global sample_rate
|
||||
data_orig = AudioSegment.from_file(audio_file, target_sr=sample_rate, offset=0)
|
||||
for snr in row['snrs']:
|
||||
min_snr_db = snr
|
||||
max_snr_db = snr
|
||||
global att_factor
|
||||
perturber = NoisePerturbation(
|
||||
manifest_path=row['noise_manifest'], min_snr_db=min_snr_db, max_snr_db=max_snr_db, rng=rng
|
||||
)
|
||||
out_dir = get_out_dir_name(
|
||||
row['out_dir'],
|
||||
os.path.splitext(os.path.basename(row['input_manifest']))[0],
|
||||
os.path.splitext(os.path.basename(row['noise_manifest']))[0],
|
||||
snr,
|
||||
)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_f = os.path.join(out_dir, os.path.basename(audio_file))
|
||||
if os.path.exists(out_f):
|
||||
continue
|
||||
data = copy.deepcopy(data_orig)
|
||||
perturber.perturb(data)
|
||||
|
||||
max_level = np.max(np.abs(data.samples))
|
||||
|
||||
norm_factor = att_factor / max_level
|
||||
new_samples = norm_factor * data.samples
|
||||
sf.write(out_f, new_samples.transpose(), sample_rate)
|
||||
|
||||
global save_noise
|
||||
if save_noise:
|
||||
noise_samples = new_samples - norm_factor * data_orig.samples
|
||||
out_f_ext = os.path.splitext(out_f)[1]
|
||||
out_f_noise = out_f.replace(out_f_ext, "_noise" + out_f_ext)
|
||||
sf.write(out_f_noise, noise_samples.transpose(), sample_rate)
|
||||
|
||||
|
||||
def add_noise(infile, snrs, noise_manifest, out_dir, num_workers=1):
|
||||
allrows = []
|
||||
|
||||
with open(infile, "r") as inf:
|
||||
for line in inf:
|
||||
row = json.loads(line.strip())
|
||||
row['snrs'] = snrs
|
||||
row['out_dir'] = out_dir
|
||||
row['noise_manifest'] = noise_manifest
|
||||
row['input_manifest'] = infile
|
||||
allrows.append(row)
|
||||
pool = multiprocessing.Pool(num_workers)
|
||||
pool.map(process_row, allrows)
|
||||
pool.close()
|
||||
print('Done!')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--input_manifest",
|
||||
type=str,
|
||||
required=True,
|
||||
help="clean test set",
|
||||
)
|
||||
parser.add_argument("--noise_manifest", type=str, required=True, help="path to noise manifest file")
|
||||
parser.add_argument("--out_dir", type=str, required=True, help="destination directory for audio and manifests")
|
||||
parser.add_argument("--snrs", type=int, nargs="+", default=[0, 10, 20, 30])
|
||||
parser.add_argument("--seed", type=int, default=None)
|
||||
parser.add_argument("--num_workers", default=1, type=int)
|
||||
parser.add_argument("--sample_rate", default=16000, type=int)
|
||||
parser.add_argument(
|
||||
"--attenuation_factor",
|
||||
default=0.8,
|
||||
type=float,
|
||||
help="Attenuation factor applied on the normalized noise-added samples before writing to wave",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save_noise", default=False, action="store_true", help="save the noise added to the input signal"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
global sample_rate
|
||||
sample_rate = args.sample_rate
|
||||
global att_factor
|
||||
att_factor = args.attenuation_factor
|
||||
global save_noise
|
||||
save_noise = args.save_noise
|
||||
global rng
|
||||
rng = args.seed
|
||||
num_workers = args.num_workers
|
||||
|
||||
add_noise(args.input_manifest, args.snrs, args.noise_manifest, args.out_dir, num_workers=num_workers)
|
||||
create_manifest(args.input_manifest, args.noise_manifest, args.snrs, args.out_dir, args.save_noise)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,122 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# USAGE:
|
||||
# python fisher_audio_to_wav.py \
|
||||
# --data_root=<FisherEnglishTrainingSpeech root> \
|
||||
# --dest_root=<destination dir root>
|
||||
#
|
||||
# Converts all .sph audio files in the Fisher dataset to .wav.
|
||||
# Requires sph2pipe to be installed.
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
parser = argparse.ArgumentParser(description='Convert Fisher .sph to .wav')
|
||||
parser.add_argument(
|
||||
"--data_root",
|
||||
default=None,
|
||||
type=str,
|
||||
required=True,
|
||||
help="The path to the root Fisher dataset folder.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dest_root",
|
||||
default=None,
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to the destination root directory.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def __convert_audio(in_path, out_path):
|
||||
"""
|
||||
Helper function that's called per thread, converts sph to wav.
|
||||
Args:
|
||||
in_path: source sph file to convert
|
||||
out_path: destination for wav file
|
||||
"""
|
||||
cmd = ["sph2pipe", "-f", "wav", "-p", in_path, out_path]
|
||||
subprocess.run(cmd)
|
||||
|
||||
|
||||
def __process_set(data_root, dst_root):
|
||||
"""
|
||||
Finds and converts all sph audio files in the given directory to wav.
|
||||
Args:
|
||||
data_folder: source directory with sph files to convert
|
||||
dst_root: where wav files will be stored
|
||||
"""
|
||||
sph_list = glob.glob(data_root)
|
||||
|
||||
if not os.path.exists(dst_root):
|
||||
os.makedirs(dst_root)
|
||||
|
||||
# Set up and execute concurrent audio conversion
|
||||
tp = concurrent.futures.ProcessPoolExecutor(max_workers=64)
|
||||
futures = []
|
||||
|
||||
for sph_path in tqdm(sph_list, desc="Submitting sph futures", unit="file"):
|
||||
audio_id, _ = os.path.splitext(os.path.basename(sph_path))
|
||||
out_path = os.path.join(dst_root, "{}.wav".format(audio_id))
|
||||
futures.append(tp.submit(__convert_audio, sph_path, out_path))
|
||||
|
||||
pbar = tqdm(total=len(sph_list), desc="Converting sph files", unit="file")
|
||||
count = 0
|
||||
for f in concurrent.futures.as_completed(futures):
|
||||
count += 1
|
||||
pbar.update()
|
||||
tp.shutdown()
|
||||
pbar.close()
|
||||
|
||||
|
||||
def main():
|
||||
data_root = args.data_root
|
||||
dest_root = args.dest_root
|
||||
|
||||
logging.info("\n\nConverting audio for Part 1")
|
||||
__process_set(
|
||||
os.path.join(
|
||||
data_root,
|
||||
"LDC2004S13-Part1",
|
||||
"fisher_eng_tr_sp_d*",
|
||||
"audio",
|
||||
"*",
|
||||
"*.sph",
|
||||
),
|
||||
os.path.join(dest_root, "LDC2004S13-Part1", "audio_wav"),
|
||||
)
|
||||
|
||||
logging.info("\n\nConverting audio for Part 2")
|
||||
__process_set(
|
||||
os.path.join(
|
||||
data_root,
|
||||
"LDC2005S13-Part2",
|
||||
"fe_03_p2_sph*",
|
||||
"audio",
|
||||
"*",
|
||||
"*.sph",
|
||||
),
|
||||
os.path.join(dest_root, "LDC2005S13-Part2", "audio_wav"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,141 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import itertools
|
||||
import os
|
||||
from argparse import ArgumentParser
|
||||
from typing import Dict
|
||||
|
||||
from syllabify import syllabify
|
||||
|
||||
|
||||
"""
|
||||
Usage:
|
||||
cd NeMo/scripts && python dataset_processing/g2p/convert_cmu_arpabet_to_ipa.py
|
||||
"""
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = ArgumentParser("ARPABET to IPA conversion sctipt")
|
||||
parser.add_argument(
|
||||
'--cmu_arpabet',
|
||||
help="Path to CMU ARPABET dictionary file",
|
||||
type=str,
|
||||
default="tts_dataset_files/cmudict-0.7b_nv22.10",
|
||||
)
|
||||
parser.add_argument("--ipa_out", help="Path to save IPA version of the dictionary", type=str, required=True)
|
||||
parser.add_argument(
|
||||
"--mapping",
|
||||
help="ARPABET to IPA phoneme mapping file",
|
||||
type=str,
|
||||
default="tts_dataset_files/cmudict-arpabet_to_ipa_nv22.10.tsv",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def convert_arp_to_ipa(arp_to_ipa_dict: Dict[str, str], arp_input: str, remove_space: bool = False) -> str:
|
||||
"""
|
||||
Converts ARPABET phoneme to IPA based on arp_to_ipa_dict mapping
|
||||
|
||||
Args:
|
||||
arp_to_ipa_dict: ARPABET to IPA phonemes mapping
|
||||
arp_input: ARPABET input
|
||||
remove_space: set to TRUE to remove spaces between IPA phonemes
|
||||
|
||||
Returns:
|
||||
input word in IPA form
|
||||
"""
|
||||
|
||||
primary_stress = "ˈ"
|
||||
secondary_stress = "ˌ"
|
||||
stress_dict = {"0": "", "1": primary_stress, "2": secondary_stress}
|
||||
|
||||
word_ipa = ""
|
||||
phonemes = arp_input.split()
|
||||
|
||||
# split ARPABET phoneme input into syllables,
|
||||
# e.g. syllabify(["HH", "AH0", "L", "OW1"]) -> [(['HH'], ['AH0'], []), (['L'], ['OW1'], [])]
|
||||
syllables = syllabify(phonemes)
|
||||
|
||||
for syl_idx, syll in enumerate(syllables):
|
||||
syll_stress = ""
|
||||
syll_ipa = ""
|
||||
|
||||
# syll is a tuple of lists of phonemes, here we flatten it and get rid of empty entries,
|
||||
# e.g. (['HH'], ['AH0'], []) -> ['HH', 'AH0']
|
||||
syll = [x for x in itertools.chain.from_iterable(syll)]
|
||||
for phon_idx, phon in enumerate(syll):
|
||||
if phon[-1].isdigit():
|
||||
syll_stress = phon[-1]
|
||||
if syll_stress not in stress_dict:
|
||||
raise ValueError(f"{syll_stress} unknown")
|
||||
syll_stress = stress_dict[syll_stress]
|
||||
|
||||
# some phonemes are followed by a digit that represents stress, e.g., `AH0`
|
||||
if phon not in arp_to_ipa_dict and phon[-1].isdigit():
|
||||
phon = phon[:-1]
|
||||
|
||||
if phon not in arp_to_ipa_dict:
|
||||
raise ValueError(f"|{phon}| phoneme not found in |{arp_input}|")
|
||||
else:
|
||||
ipa_phone = arp_to_ipa_dict[phon]
|
||||
syll_ipa += ipa_phone + " "
|
||||
|
||||
word_ipa += " " + syll_stress + syll_ipa.strip()
|
||||
|
||||
word_ipa = word_ipa.strip()
|
||||
if remove_space:
|
||||
word_ipa = word_ipa.replace(" ", "")
|
||||
return word_ipa
|
||||
|
||||
|
||||
def _get_arpabet_to_ipa_mapping(arp_ipa_map_file: str) -> Dict[str, str]:
|
||||
"""
|
||||
arp_ipa_map_file: Arpabet to IPA phonemes mapping
|
||||
"""
|
||||
arp_to_ipa = {}
|
||||
with open(arp_ipa_map_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
arp, ipa = line.strip().split("\t")
|
||||
arp_to_ipa[arp] = ipa
|
||||
return arp_to_ipa
|
||||
|
||||
|
||||
def convert_cmu_arpabet_to_ipa(arp_ipa_map_file: str, arp_dict_file: str, output_ipa_file: str):
|
||||
"""
|
||||
Converts CMU ARPABET-based dictionary to IPA.
|
||||
|
||||
Args:
|
||||
arp_ipa_map_file: ARPABET to IPA phoneme mapping file
|
||||
arp_dict_file: path to ARPABET version of CMU dictionary
|
||||
output_ipa_file: path to output IPA version of CMU dictionary
|
||||
"""
|
||||
arp_to_ipa_dict = _get_arpabet_to_ipa_mapping(arp_ipa_map_file)
|
||||
with open(arp_dict_file, "r", encoding="utf-8") as f_arp, open(output_ipa_file, "w", encoding="utf-8") as f_ipa:
|
||||
for line in f_arp:
|
||||
if line.startswith(";;;"):
|
||||
f_ipa.write(line)
|
||||
else:
|
||||
# First, split the line at " #" if there are comments in the dictionary file following the mapping entries.
|
||||
# Next, split at default " " separator.
|
||||
graphemes, phonemes = line.split(" #")[0].strip().split(" ")
|
||||
ipa_form = convert_arp_to_ipa(arp_to_ipa_dict, phonemes, remove_space=True)
|
||||
f_ipa.write(f"{graphemes} {ipa_form}\n")
|
||||
|
||||
print(f"IPA version of {os.path.abspath(arp_dict_file)} saved in {os.path.abspath(output_ipa_file)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
convert_cmu_arpabet_to_ipa(args.mapping, args.cmu_arpabet, args.ipa_out)
|
||||
@@ -0,0 +1,288 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Copyright (c) 2012-2013 Kyle Gorman <gormanky@ohsu.edu>
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a
|
||||
# copy of this software and associated documentation files (the
|
||||
# "Software"), to deal in the Software without restriction, including
|
||||
# without limitation the rights to use, copy, modify, merge, publish,
|
||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
||||
# permit persons to whom the Software is furnished to do so, subject to
|
||||
# the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included
|
||||
# in all copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
# syllabify.py: prosodic parsing of ARPABET entries
|
||||
# source: https://github.com/kylebgorman/syllabify
|
||||
|
||||
from itertools import chain
|
||||
|
||||
## constants
|
||||
SLAX = {
|
||||
"IH1",
|
||||
"IH2",
|
||||
"EH1",
|
||||
"EH2",
|
||||
"AE1",
|
||||
"AE2",
|
||||
"AH1",
|
||||
"AH2",
|
||||
"UH1",
|
||||
"UH2",
|
||||
}
|
||||
VOWELS = {
|
||||
"IY1",
|
||||
"IY2",
|
||||
"IY0",
|
||||
"EY1",
|
||||
"EY2",
|
||||
"EY0",
|
||||
"AA1",
|
||||
"AA2",
|
||||
"AA0",
|
||||
"ER1",
|
||||
"ER2",
|
||||
"ER0",
|
||||
"AW1",
|
||||
"AW2",
|
||||
"AW0",
|
||||
"AO1",
|
||||
"AO2",
|
||||
"AO0",
|
||||
"AY1",
|
||||
"AY2",
|
||||
"AY0",
|
||||
"OW1",
|
||||
"OW2",
|
||||
"OW0",
|
||||
"OY1",
|
||||
"OY2",
|
||||
"OY0",
|
||||
"IH0",
|
||||
"EH0",
|
||||
"AE0",
|
||||
"AH0",
|
||||
"UH0",
|
||||
"UW1",
|
||||
"UW2",
|
||||
"UW0",
|
||||
"UW",
|
||||
"IY",
|
||||
"EY",
|
||||
"AA",
|
||||
"ER",
|
||||
"AW",
|
||||
"AO",
|
||||
"AY",
|
||||
"OW",
|
||||
"OY",
|
||||
"UH",
|
||||
"IH",
|
||||
"EH",
|
||||
"AE",
|
||||
"AH",
|
||||
"UH",
|
||||
} | SLAX
|
||||
|
||||
## licit medial onsets
|
||||
|
||||
O2 = {
|
||||
("P", "R"),
|
||||
("T", "R"),
|
||||
("K", "R"),
|
||||
("B", "R"),
|
||||
("D", "R"),
|
||||
("G", "R"),
|
||||
("F", "R"),
|
||||
("TH", "R"),
|
||||
("P", "L"),
|
||||
("K", "L"),
|
||||
("B", "L"),
|
||||
("G", "L"),
|
||||
("F", "L"),
|
||||
("S", "L"),
|
||||
("K", "W"),
|
||||
("G", "W"),
|
||||
("S", "W"),
|
||||
("S", "P"),
|
||||
("S", "T"),
|
||||
("S", "K"),
|
||||
("HH", "Y"), # "clerihew"
|
||||
("R", "W"),
|
||||
}
|
||||
O3 = {("S", "T", "R"), ("S", "K", "L"), ("T", "R", "W")} # "octroi"
|
||||
|
||||
# This does not represent anything like a complete list of onsets, but
|
||||
# merely those that need to be maximized in medial position.
|
||||
|
||||
|
||||
def syllabify(pron, alaska_rule=True):
|
||||
"""
|
||||
Syllabifies a CMU dictionary (ARPABET) word string
|
||||
|
||||
# Alaska rule:
|
||||
>>> pprint(syllabify('AH0 L AE1 S K AH0'.split())) # Alaska
|
||||
'-AH0-.L-AE1-S.K-AH0-'
|
||||
>>> pprint(syllabify('AH0 L AE1 S K AH0'.split(), 0)) # Alaska
|
||||
'-AH0-.L-AE1-.S K-AH0-'
|
||||
|
||||
# huge medial onsets:
|
||||
>>> pprint(syllabify('M IH1 N S T R AH0 L'.split())) # minstrel
|
||||
'M-IH1-N.S T R-AH0-L'
|
||||
>>> pprint(syllabify('AA1 K T R W AA0 R'.split())) # octroi
|
||||
'-AA1-K.T R W-AA0-R'
|
||||
|
||||
# destressing
|
||||
>>> pprint(destress(syllabify('M IH1 L AH0 T EH2 R IY0'.split())))
|
||||
'M-IH-.L-AH-.T-EH-.R-IY-'
|
||||
|
||||
# normal treatment of 'j':
|
||||
>>> pprint(syllabify('M EH1 N Y UW0'.split())) # menu
|
||||
'M-EH1-N.Y-UW0-'
|
||||
>>> pprint(syllabify('S P AE1 N Y AH0 L'.split())) # spaniel
|
||||
'S P-AE1-N.Y-AH0-L'
|
||||
>>> pprint(syllabify('K AE1 N Y AH0 N'.split())) # canyon
|
||||
'K-AE1-N.Y-AH0-N'
|
||||
>>> pprint(syllabify('M IH0 N Y UW2 EH1 T'.split())) # minuet
|
||||
'M-IH0-N.Y-UW2-.-EH1-T'
|
||||
>>> pprint(syllabify('JH UW1 N Y ER0'.split())) # junior
|
||||
'JH-UW1-N.Y-ER0-'
|
||||
>>> pprint(syllabify('K L EH R IH HH Y UW'.split())) # clerihew
|
||||
'K L-EH-.R-IH-.HH Y-UW-'
|
||||
|
||||
# nuclear treatment of 'j'
|
||||
>>> pprint(syllabify('R EH1 S K Y UW0'.split())) # rescue
|
||||
'R-EH1-S.K-Y UW0-'
|
||||
>>> pprint(syllabify('T R IH1 B Y UW0 T'.split())) # tribute
|
||||
'T R-IH1-B.Y-UW0-T'
|
||||
>>> pprint(syllabify('N EH1 B Y AH0 L AH0'.split())) # nebula
|
||||
'N-EH1-B.Y-AH0-.L-AH0-'
|
||||
>>> pprint(syllabify('S P AE1 CH UH0 L AH0'.split())) # spatula
|
||||
'S P-AE1-.CH-UH0-.L-AH0-'
|
||||
>>> pprint(syllabify('AH0 K Y UW1 M AH0 N'.split())) # acumen
|
||||
'-AH0-K.Y-UW1-.M-AH0-N'
|
||||
>>> pprint(syllabify('S AH1 K Y AH0 L IH0 N T'.split())) # succulent
|
||||
'S-AH1-K.Y-AH0-.L-IH0-N T'
|
||||
>>> pprint(syllabify('F AO1 R M Y AH0 L AH0'.split())) # formula
|
||||
'F-AO1 R-M.Y-AH0-.L-AH0-'
|
||||
>>> pprint(syllabify('V AE1 L Y UW0'.split())) # value
|
||||
'V-AE1-L.Y-UW0-'
|
||||
|
||||
# everything else
|
||||
>>> pprint(syllabify('N AO0 S T AE1 L JH IH0 K'.split())) # nostalgic
|
||||
'N-AO0-.S T-AE1-L.JH-IH0-K'
|
||||
>>> pprint(syllabify('CH ER1 CH M AH0 N'.split())) # churchmen
|
||||
'CH-ER1-CH.M-AH0-N'
|
||||
>>> pprint(syllabify('K AA1 M P AH0 N S EY2 T'.split())) # compensate
|
||||
'K-AA1-M.P-AH0-N.S-EY2-T'
|
||||
>>> pprint(syllabify('IH0 N S EH1 N S'.split())) # inCENSE
|
||||
'-IH0-N.S-EH1-N S'
|
||||
>>> pprint(syllabify('IH1 N S EH2 N S'.split())) # INcense
|
||||
'-IH1-N.S-EH2-N S'
|
||||
>>> pprint(syllabify('AH0 S EH1 N D'.split())) # ascend
|
||||
'-AH0-.S-EH1-N D'
|
||||
>>> pprint(syllabify('R OW1 T EY2 T'.split())) # rotate
|
||||
'R-OW1-.T-EY2-T'
|
||||
>>> pprint(syllabify('AA1 R T AH0 S T'.split())) # artist
|
||||
'-AA1 R-.T-AH0-S T'
|
||||
>>> pprint(syllabify('AE1 K T ER0'.split())) # actor
|
||||
'-AE1-K.T-ER0-'
|
||||
>>> pprint(syllabify('P L AE1 S T ER0'.split())) # plaster
|
||||
'P L-AE1-S.T-ER0-'
|
||||
>>> pprint(syllabify('B AH1 T ER0'.split())) # butter
|
||||
'B-AH1-.T-ER0-'
|
||||
>>> pprint(syllabify('K AE1 M AH0 L'.split())) # camel
|
||||
'K-AE1-.M-AH0-L'
|
||||
>>> pprint(syllabify('AH1 P ER0'.split())) # upper
|
||||
'-AH1-.P-ER0-'
|
||||
>>> pprint(syllabify('B AH0 L UW1 N'.split())) # balloon
|
||||
'B-AH0-.L-UW1-N'
|
||||
>>> pprint(syllabify('P R OW0 K L EY1 M'.split())) # proclaim
|
||||
'P R-OW0-.K L-EY1-M'
|
||||
>>> pprint(syllabify('IH0 N S EY1 N'.split())) # insane
|
||||
'-IH0-N.S-EY1-N'
|
||||
>>> pprint(syllabify('IH0 K S K L UW1 D'.split())) # exclude
|
||||
'-IH0-K.S K L-UW1-D'
|
||||
"""
|
||||
## main pass
|
||||
mypron = list(pron)
|
||||
nuclei = []
|
||||
onsets = []
|
||||
i = -1
|
||||
for j, seg in enumerate(mypron):
|
||||
if seg in VOWELS:
|
||||
nuclei.append([seg])
|
||||
onsets.append(mypron[i + 1 : j]) # actually interludes, r.n.
|
||||
i = j
|
||||
codas = [mypron[i + 1 :]]
|
||||
## resolve disputes and compute coda
|
||||
for i in range(1, len(onsets)):
|
||||
coda = []
|
||||
# boundary cases
|
||||
if len(onsets[i]) > 1 and onsets[i][0] == "R":
|
||||
nuclei[i - 1].append(onsets[i].pop(0))
|
||||
if len(onsets[i]) > 2 and onsets[i][-1] == "Y":
|
||||
nuclei[i].insert(0, onsets[i].pop())
|
||||
if len(onsets[i]) > 1 and alaska_rule and nuclei[i - 1][-1] in SLAX and onsets[i][0] == "S":
|
||||
coda.append(onsets[i].pop(0))
|
||||
# onset maximization
|
||||
depth = 1
|
||||
if len(onsets[i]) > 1:
|
||||
if tuple(onsets[i][-2:]) in O2:
|
||||
depth = 3 if tuple(onsets[i][-3:]) in O3 else 2
|
||||
for j in range(len(onsets[i]) - depth):
|
||||
coda.append(onsets[i].pop(0))
|
||||
# store coda
|
||||
codas.insert(i - 1, coda)
|
||||
|
||||
## verify that all segments are included in the ouput
|
||||
output = list(zip(onsets, nuclei, codas)) # in Python3 zip is a generator
|
||||
flat_output = list(chain.from_iterable(chain.from_iterable(output)))
|
||||
if flat_output != mypron:
|
||||
raise ValueError(f"could not syllabify {mypron}, got {flat_output}")
|
||||
return output
|
||||
|
||||
|
||||
def pprint(syllab):
|
||||
"""
|
||||
Pretty-print a syllabification
|
||||
"""
|
||||
return ".".join("-".join(" ".join(p) for p in syl) for syl in syllab)
|
||||
|
||||
|
||||
def destress(syllab):
|
||||
"""
|
||||
Generate a syllabification with nuclear stress information removed
|
||||
"""
|
||||
syls = []
|
||||
for onset, nucleus, coda in syllab:
|
||||
nuke = [p[:-1] if p[-1] in {"0", "1", "2"} else p for p in nucleus]
|
||||
syls.append((onset, nuke, coda))
|
||||
return syls
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import doctest
|
||||
|
||||
doctest.testmod()
|
||||
@@ -0,0 +1,177 @@
|
||||
# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# USAGE: python get_aishell_data.py --data_root=<where to put data>
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import urllib.request
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.utils.tar_utils import safe_extract
|
||||
|
||||
parser = argparse.ArgumentParser(description="Aishell Data download")
|
||||
parser.add_argument("--data_root", required=True, default=None, type=str)
|
||||
args = parser.parse_args()
|
||||
|
||||
URL = {"data_aishell": "http://www.openslr.org/resources/33/data_aishell.tgz"}
|
||||
|
||||
|
||||
def __retrieve_with_progress(source: str, filename: str):
|
||||
"""
|
||||
Downloads source to destination
|
||||
Displays progress bar
|
||||
Args:
|
||||
source: url of resource
|
||||
destination: local filepath
|
||||
Returns:
|
||||
"""
|
||||
with open(filename, "wb") as f:
|
||||
response = urllib.request.urlopen(source)
|
||||
total = response.length
|
||||
|
||||
if total is None:
|
||||
f.write(response.content)
|
||||
else:
|
||||
with tqdm(total=total, unit="B", unit_scale=True, unit_divisor=1024) as pbar:
|
||||
for data in response:
|
||||
f.write(data)
|
||||
pbar.update(len(data))
|
||||
|
||||
|
||||
def __maybe_download_file(destination: str, source: str):
|
||||
"""
|
||||
Downloads source to destination if it doesn't exist.
|
||||
If exists, skips download
|
||||
Args:
|
||||
destination: local filepath
|
||||
source: url of resource
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
source = URL[source]
|
||||
if not os.path.exists(destination):
|
||||
logging.info("{0} does not exist. Downloading ...".format(destination))
|
||||
__retrieve_with_progress(source, filename=destination + ".tmp")
|
||||
os.rename(destination + ".tmp", destination)
|
||||
logging.info("Downloaded {0}.".format(destination))
|
||||
else:
|
||||
logging.info("Destination {0} exists. Skipping.".format(destination))
|
||||
return destination
|
||||
|
||||
|
||||
def __extract_all_files(filepath: str, data_root: str, data_dir: str):
|
||||
if not os.path.exists(data_dir):
|
||||
extract_file(filepath, data_root)
|
||||
audio_dir = os.path.join(data_dir, "wav")
|
||||
for subfolder, _, filelist in os.walk(audio_dir):
|
||||
for ftar in filelist:
|
||||
extract_file(os.path.join(subfolder, ftar), subfolder)
|
||||
else:
|
||||
logging.info("Skipping extracting. Data already there %s" % data_dir)
|
||||
|
||||
|
||||
def extract_file(filepath: str, data_dir: str):
|
||||
try:
|
||||
with tarfile.open(filepath) as tar:
|
||||
safe_extract(tar, data_dir)
|
||||
except Exception:
|
||||
logging.info("Not extracting. Maybe already there?")
|
||||
|
||||
|
||||
def __process_data(data_folder: str, dst_folder: str):
|
||||
"""
|
||||
To generate manifest
|
||||
Args:
|
||||
data_folder: source with wav files
|
||||
dst_folder: where manifest files will be stored
|
||||
Returns:
|
||||
|
||||
"""
|
||||
|
||||
if not os.path.exists(dst_folder):
|
||||
os.makedirs(dst_folder)
|
||||
|
||||
transcript_file = os.path.join(data_folder, "transcript", "aishell_transcript_v0.8.txt")
|
||||
transcript_dict = {}
|
||||
with open(transcript_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
audio_id, text = line.split(" ", 1)
|
||||
# remove white space
|
||||
text = text.replace(" ", "")
|
||||
transcript_dict[audio_id] = text
|
||||
|
||||
data_types = ["train", "dev", "test"]
|
||||
vocab_count = {}
|
||||
for dt in data_types:
|
||||
json_lines = []
|
||||
audio_dir = os.path.join(data_folder, "wav", dt)
|
||||
for sub_folder, _, file_list in os.walk(audio_dir):
|
||||
for fname in file_list:
|
||||
audio_path = os.path.join(sub_folder, fname)
|
||||
audio_id = fname.strip(".wav")
|
||||
if audio_id not in transcript_dict:
|
||||
continue
|
||||
text = transcript_dict[audio_id]
|
||||
for li in text:
|
||||
vocab_count[li] = vocab_count.get(li, 0) + 1
|
||||
duration = subprocess.check_output(["soxi", "-D", audio_path])
|
||||
duration = float(duration)
|
||||
json_lines.append(
|
||||
json.dumps(
|
||||
{
|
||||
"audio_filepath": os.path.abspath(audio_path),
|
||||
"duration": duration,
|
||||
"text": text,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
manifest_path = os.path.join(dst_folder, dt + ".json")
|
||||
with open(manifest_path, "w", encoding="utf-8") as fout:
|
||||
for line in json_lines:
|
||||
fout.write(line + "\n")
|
||||
|
||||
vocab = sorted(vocab_count.items(), key=lambda k: k[1], reverse=True)
|
||||
vocab_file = os.path.join(dst_folder, "vocab.txt")
|
||||
with open(vocab_file, "w", encoding="utf-8") as f:
|
||||
for v, c in vocab:
|
||||
f.write(v + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
data_root = args.data_root
|
||||
data_set = "data_aishell"
|
||||
logging.info("\n\nWorking on: {0}".format(data_set))
|
||||
file_path = os.path.join(data_root, data_set + ".tgz")
|
||||
logging.info("Getting {0}".format(data_set))
|
||||
__maybe_download_file(file_path, data_set)
|
||||
logging.info("Extracting {0}".format(data_set))
|
||||
data_folder = os.path.join(data_root, data_set)
|
||||
__extract_all_files(file_path, data_root, data_folder)
|
||||
logging.info("Processing {0}".format(data_set))
|
||||
__process_data(data_folder, data_folder)
|
||||
logging.info("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,229 @@
|
||||
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
# Copyright (c) 2020, SeanNaren. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
# To convert mp3 files to wav using sox, you must have installed sox with mp3 support
|
||||
# For example sudo apt-get install libsox-fmt-mp3
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import sys
|
||||
import tarfile
|
||||
import urllib.request
|
||||
from multiprocessing.pool import ThreadPool
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.utils.tar_utils import safe_extract
|
||||
|
||||
parser = argparse.ArgumentParser(description='Downloads and processes Mozilla Common Voice dataset.')
|
||||
parser.add_argument("--data_root", default='CommonVoice_dataset/', type=str, help="Directory to store the dataset.")
|
||||
parser.add_argument('--manifest_dir', default='./', type=str, help='Output directory for manifests')
|
||||
parser.add_argument("--num_workers", default=multiprocessing.cpu_count(), type=int, help="Workers to process dataset.")
|
||||
parser.add_argument('--sample_rate', default=16000, type=int, help='Sample rate')
|
||||
parser.add_argument('--n_channels', default=1, type=int, help='Number of channels for output wav files')
|
||||
parser.add_argument("--log", dest="log", action="store_true", default=False)
|
||||
parser.add_argument("--cleanup", dest="cleanup", action="store_true", default=False)
|
||||
parser.add_argument(
|
||||
'--files_to_process',
|
||||
nargs='+',
|
||||
default=['test.tsv', 'dev.tsv', 'train.tsv'],
|
||||
type=str,
|
||||
help='list of *.csv file names to process',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--version',
|
||||
default='cv-corpus-5.1-2020-06-22',
|
||||
type=str,
|
||||
help='Version of the dataset (obtainable via https://commonvoice.mozilla.org/en/datasets',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--language',
|
||||
default='en',
|
||||
type=str,
|
||||
help='Which language to download.(default english,'
|
||||
'check https://commonvoice.mozilla.org/en/datasets for more language codes',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
COMMON_VOICE_URL = (
|
||||
f"https://voice-prod-bundler-ee1969a6ce8178826482b88e843c335139bd3fb4.s3.amazonaws.com/"
|
||||
"{}/{}.tar.gz".format(args.version, args.language)
|
||||
)
|
||||
COMMON_VOICE_USER_AGENT = (
|
||||
'Mozilla/5.0 (Windows NT 10.0; WOW64) ' 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36'
|
||||
)
|
||||
|
||||
|
||||
def _load_sox():
|
||||
try:
|
||||
import sox
|
||||
from sox import Transformer
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Optional dependency 'sox' is required by this script. Install it with: pip install sox"
|
||||
) from None
|
||||
|
||||
return sox, Transformer
|
||||
|
||||
|
||||
def download_commonvoice_archive(url: str, output_path: str):
|
||||
request = urllib.request.Request(url, headers={'User-Agent': COMMON_VOICE_USER_AGENT})
|
||||
with urllib.request.urlopen(request) as response, open(output_path, 'wb') as f:
|
||||
while True:
|
||||
chunk = response.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
def create_manifest(data: List[tuple], output_name: str, manifest_path: str):
|
||||
output_file = Path(manifest_path) / output_name
|
||||
output_file.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
with output_file.open(mode='w') as f:
|
||||
for wav_path, duration, text in tqdm(data, total=len(data)):
|
||||
if wav_path != '':
|
||||
# skip invalid input files that could not be converted
|
||||
f.write(
|
||||
json.dumps({'audio_filepath': os.path.abspath(wav_path), "duration": duration, 'text': text})
|
||||
+ '\n'
|
||||
)
|
||||
|
||||
|
||||
def process_files(csv_file, data_root, num_workers):
|
||||
"""Read *.csv file description, convert mp3 to wav, process text.
|
||||
Save results to data_root.
|
||||
|
||||
Args:
|
||||
csv_file: str, path to *.csv file with data description, usually start from 'cv-'
|
||||
data_root: str, path to dir to save results; wav/ dir will be created
|
||||
"""
|
||||
sox, Transformer = _load_sox()
|
||||
wav_dir = os.path.join(data_root, 'wav/')
|
||||
os.makedirs(wav_dir, exist_ok=True)
|
||||
audio_clips_path = os.path.dirname(csv_file) + '/clips/'
|
||||
|
||||
def process(x):
|
||||
file_path, text = x
|
||||
file_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
text = text.lower().strip()
|
||||
audio_path = os.path.join(audio_clips_path, file_path)
|
||||
if os.path.getsize(audio_path) == 0:
|
||||
logging.warning(f'Skipping empty audio file {audio_path}')
|
||||
return '', '', ''
|
||||
|
||||
output_wav_path = os.path.join(wav_dir, file_name + '.wav')
|
||||
|
||||
if not os.path.exists(output_wav_path):
|
||||
tfm = Transformer()
|
||||
tfm.rate(samplerate=args.sample_rate)
|
||||
tfm.channels(n_channels=args.n_channels)
|
||||
tfm.build(input_filepath=audio_path, output_filepath=output_wav_path)
|
||||
|
||||
duration = sox.file_info.duration(output_wav_path)
|
||||
return output_wav_path, duration, text
|
||||
|
||||
logging.info('Converting mp3 to wav for {}.'.format(csv_file))
|
||||
with open(csv_file) as csvfile:
|
||||
reader = csv.DictReader(csvfile, delimiter='\t')
|
||||
next(reader, None) # skip the headers
|
||||
data = []
|
||||
for row in reader:
|
||||
file_name = row['path']
|
||||
# add the mp3 extension if the tsv entry does not have it
|
||||
if not file_name.endswith('.mp3'):
|
||||
file_name += '.mp3'
|
||||
data.append((file_name, row['sentence']))
|
||||
with ThreadPool(num_workers) as pool:
|
||||
data = list(tqdm(pool.imap(process, data), total=len(data)))
|
||||
return data
|
||||
|
||||
|
||||
def main():
|
||||
if args.log:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
data_root = args.data_root
|
||||
os.makedirs(data_root, exist_ok=True)
|
||||
|
||||
target_unpacked_dir = os.path.join(data_root, "CV_unpacked")
|
||||
|
||||
if os.path.exists(target_unpacked_dir):
|
||||
logging.info('Find existing folder {}'.format(target_unpacked_dir))
|
||||
else:
|
||||
logging.info("Could not find Common Voice, Downloading corpus...")
|
||||
|
||||
# some dataset versions are packaged in different named files, so forcing
|
||||
output_archive_filename = args.language + '.tar.gz'
|
||||
output_archive_filename = os.path.join(data_root, output_archive_filename)
|
||||
|
||||
download_commonvoice_archive(COMMON_VOICE_URL, output_archive_filename)
|
||||
filename = f"{args.language}.tar.gz"
|
||||
target_file = os.path.join(data_root, os.path.basename(filename))
|
||||
|
||||
os.makedirs(target_unpacked_dir, exist_ok=True)
|
||||
logging.info("Unpacking corpus to {} ...".format(target_unpacked_dir))
|
||||
with tarfile.open(target_file) as tar:
|
||||
safe_extract(tar, target_unpacked_dir)
|
||||
if args.cleanup:
|
||||
logging.info("removing tar archive to save space")
|
||||
os.remove(target_file)
|
||||
|
||||
folder_path = os.path.join(target_unpacked_dir, args.version + f'/{args.language}/')
|
||||
if not os.path.isdir(folder_path):
|
||||
# try without language
|
||||
folder_path = os.path.join(target_unpacked_dir, args.version)
|
||||
if not os.path.isdir(folder_path):
|
||||
# try without version
|
||||
folder_path = target_unpacked_dir
|
||||
if not os.path.isdir(folder_path):
|
||||
logging.error(f'unable to locate unpacked files in {folder_path}')
|
||||
sys.exit()
|
||||
|
||||
for csv_file in args.files_to_process:
|
||||
data = process_files(
|
||||
csv_file=os.path.join(folder_path, csv_file),
|
||||
data_root=os.path.join(data_root, os.path.splitext(csv_file)[0]),
|
||||
num_workers=args.num_workers,
|
||||
)
|
||||
logging.info('Creating manifests...')
|
||||
create_manifest(
|
||||
data=data,
|
||||
output_name=f'commonvoice_{os.path.splitext(csv_file)[0]}_manifest.json',
|
||||
manifest_path=args.manifest_dir,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# USAGE: python get_demand_data.py --data_root=<where to put data>
|
||||
# --data_set=<datasets_to_download>
|
||||
# where <datasets_to_download> can be: one or more of the 16 kHz noise profiles
|
||||
# listed at https://zenodo.org/record/1227121#.Ygb4avXMKJk ,
|
||||
# or ALL
|
||||
# You can put more than one data_set comma-separated:
|
||||
# --data_sets=DKITCHEN,DLIVING,NRIVER
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.request
|
||||
|
||||
parser = argparse.ArgumentParser(description='LibriSpeech Data download')
|
||||
parser.add_argument("--data_root", required=True, default=None, type=str)
|
||||
parser.add_argument("--data_sets", default="ALL", type=str)
|
||||
|
||||
parser.add_argument('--log', dest='log', action='store_true', default=False)
|
||||
args = parser.parse_args()
|
||||
|
||||
URLS = {
|
||||
'DKITCHEN': ("https://zenodo.org/record/1227121/files/DKITCHEN_16k.zip"),
|
||||
'DLIVING': ("https://zenodo.org/record/1227121/files/DLIVING_16k.zip"),
|
||||
'DWASHING': ("https://zenodo.org/record/1227121/files/DWASHING_16k.zip"),
|
||||
'NFIELD': ("https://zenodo.org/record/1227121/files/NFIELD_16k.zip"),
|
||||
'NPARK': ("https://zenodo.org/record/1227121/files/NPARK_16k.zip"),
|
||||
'NRIVER': ("https://zenodo.org/record/1227121/files/NRIVER_16k.zip"),
|
||||
'OHALLWAY': ("https://zenodo.org/record/1227121/files/OHALLWAY_16k.zip"),
|
||||
'OMEETING': ("https://zenodo.org/record/1227121/files/OMEETING_16k.zip"),
|
||||
'OOFFICE': ("https://zenodo.org/record/1227121/files/OOFFICE_16k.zip"),
|
||||
'PCAFETER': ("https://zenodo.org/record/1227121/files/PCAFETER_16k.zip"),
|
||||
'PRESTO': ("https://zenodo.org/record/1227121/files/PRESTO_16k.zip"),
|
||||
'PSTATION': ("https://zenodo.org/record/1227121/files/PSTATION_16k.zip"),
|
||||
'SPSQUARE': ("https://zenodo.org/record/1227121/files/SPSQUARE_16k.zip"),
|
||||
'STRAFFIC': ("https://zenodo.org/record/1227121/files/STRAFFIC_16k.zip"),
|
||||
'TBUS': ("https://zenodo.org/record/1227121/files/TBUS_16k.zip"),
|
||||
'TCAR': ("https://zenodo.org/record/1227121/files/TCAR_16k.zip"),
|
||||
'TMETRO': ("https://zenodo.org/record/1227121/files/TMETRO_16k.zip"),
|
||||
}
|
||||
|
||||
|
||||
def __maybe_download_file(destination: str, source: str):
|
||||
"""
|
||||
Downloads source to destination if it doesn't exist.
|
||||
If exists, skips download
|
||||
Args:
|
||||
destination: local filepath
|
||||
source: url of resource
|
||||
Returns:
|
||||
"""
|
||||
source = URLS[source]
|
||||
if not os.path.exists(destination):
|
||||
logging.info("{0} does not exist. Downloading ...".format(destination))
|
||||
urllib.request.urlretrieve(source, filename=destination + '.tmp')
|
||||
os.rename(destination + '.tmp', destination)
|
||||
logging.info("Downloaded {0}.".format(destination))
|
||||
else:
|
||||
logging.info("Destination {0} exists. Skipping.".format(destination))
|
||||
return destination
|
||||
|
||||
|
||||
def __extract_file(filepath: str, data_dir: str):
|
||||
shutil.unpack_archive(filepath, data_dir)
|
||||
|
||||
|
||||
def __create_manifest(dst_folder: str):
|
||||
"""
|
||||
Create manifests for the noise files
|
||||
Args:
|
||||
file_path: path to a source transcript with flac sources
|
||||
dst_folder: path where manifests will be created
|
||||
Returns:
|
||||
|
||||
a list of metadata entries for processed files.
|
||||
"""
|
||||
# Read directory
|
||||
# Get all wav file names
|
||||
# create line per wav file in manifest
|
||||
noise_name = os.path.basename(dst_folder)
|
||||
wav_files = glob.glob(dst_folder + "/*.wav")
|
||||
wav_files.sort()
|
||||
os.makedirs(os.path.join(os.path.dirname(dst_folder), "manifests"), exist_ok=True)
|
||||
with open(os.path.join(os.path.dirname(dst_folder), "manifests", noise_name + ".json"), "w") as mfst_f:
|
||||
for wav_f in wav_files:
|
||||
dur = subprocess.check_output(["soxi", "-D", wav_f])
|
||||
row = {"audio_filepath": wav_f, "text": "", "duration": float(dur)}
|
||||
mfst_f.write(json.dumps(row) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
data_root = args.data_root
|
||||
data_sets = args.data_sets
|
||||
|
||||
if args.log:
|
||||
print("here")
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
if not os.path.exists(data_root):
|
||||
os.makedirs(data_root)
|
||||
|
||||
if data_sets == "ALL":
|
||||
data_sets = URLS.keys()
|
||||
else:
|
||||
data_sets = data_sets.split(',')
|
||||
|
||||
for data_set in data_sets:
|
||||
if data_set not in URLS.keys():
|
||||
raise ValueError(f"{data_sets} is not part of demand noise database")
|
||||
logging.info("\n\nWorking on: {0}".format(data_set))
|
||||
filepath = os.path.join(data_root, data_set + "_16k.zip")
|
||||
logging.info("Getting {0}".format(data_set))
|
||||
__maybe_download_file(filepath, data_set.upper())
|
||||
logging.info("Extracting {0}".format(data_set))
|
||||
__extract_file(filepath, data_root)
|
||||
logging.info("Processing {0}".format(data_set))
|
||||
__create_manifest(os.path.join(data_root, data_set))
|
||||
logging.info('Done!')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,221 @@
|
||||
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# USAGE: python get_librispeech_data.py --data_root=<where to put data>
|
||||
# --data_set=<datasets_to_download> --num_workers=<number of parallel workers>
|
||||
# where <datasets_to_download> can be: dev_clean, dev_other, test_clean,
|
||||
# test_other, train_clean_100, train_clean_360, train_other_500 or ALL
|
||||
# You can also put more than one data_set comma-separated:
|
||||
# --data_set=dev_clean,train_clean_100
|
||||
import argparse
|
||||
import fnmatch
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import urllib.request
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.utils.tar_utils import safe_extract
|
||||
|
||||
parser = argparse.ArgumentParser(description="LibriSpeech Data download")
|
||||
parser.add_argument("--data_root", required=True, default=None, type=str)
|
||||
parser.add_argument("--data_sets", default="dev_clean", type=str)
|
||||
parser.add_argument("--num_workers", default=4, type=int)
|
||||
parser.add_argument("--log", dest="log", action="store_true", default=False)
|
||||
args = parser.parse_args()
|
||||
|
||||
URLS = {
|
||||
"TRAIN_CLEAN_100": ("http://www.openslr.org/resources/12/train-clean-100.tar.gz"),
|
||||
"TRAIN_CLEAN_360": ("http://www.openslr.org/resources/12/train-clean-360.tar.gz"),
|
||||
"TRAIN_OTHER_500": ("http://www.openslr.org/resources/12/train-other-500.tar.gz"),
|
||||
"DEV_CLEAN": "http://www.openslr.org/resources/12/dev-clean.tar.gz",
|
||||
"DEV_OTHER": "http://www.openslr.org/resources/12/dev-other.tar.gz",
|
||||
"TEST_CLEAN": "http://www.openslr.org/resources/12/test-clean.tar.gz",
|
||||
"TEST_OTHER": "http://www.openslr.org/resources/12/test-other.tar.gz",
|
||||
"DEV_CLEAN_2": "https://www.openslr.org/resources/31/dev-clean-2.tar.gz",
|
||||
"TRAIN_CLEAN_5": "https://www.openslr.org/resources/31/train-clean-5.tar.gz",
|
||||
}
|
||||
|
||||
|
||||
def _load_sox_transformer():
|
||||
try:
|
||||
from sox import Transformer
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Optional dependency 'sox' is required by this script. Install it with: pip install sox"
|
||||
) from None
|
||||
|
||||
return Transformer
|
||||
|
||||
|
||||
def __retrieve_with_progress(source: str, filename: str):
|
||||
"""
|
||||
Downloads source to destination
|
||||
Displays progress bar
|
||||
Args:
|
||||
source: url of resource
|
||||
destination: local filepath
|
||||
Returns:
|
||||
"""
|
||||
with open(filename, "wb") as f:
|
||||
response = urllib.request.urlopen(source)
|
||||
total = response.length
|
||||
|
||||
if total is None:
|
||||
f.write(response.content)
|
||||
else:
|
||||
with tqdm(total=total, unit="B", unit_scale=True, unit_divisor=1024) as pbar:
|
||||
for data in response:
|
||||
f.write(data)
|
||||
pbar.update(len(data))
|
||||
|
||||
|
||||
def __maybe_download_file(destination: str, source: str):
|
||||
"""
|
||||
Downloads source to destination if it doesn't exist.
|
||||
If exists, skips download
|
||||
Args:
|
||||
destination: local filepath
|
||||
source: url of resource
|
||||
Returns:
|
||||
"""
|
||||
source = URLS[source]
|
||||
if not os.path.exists(destination):
|
||||
logging.info("{0} does not exist. Downloading ...".format(destination))
|
||||
|
||||
__retrieve_with_progress(source, filename=destination + ".tmp")
|
||||
|
||||
os.rename(destination + ".tmp", destination)
|
||||
logging.info("Downloaded {0}.".format(destination))
|
||||
else:
|
||||
logging.info("Destination {0} exists. Skipping.".format(destination))
|
||||
return destination
|
||||
|
||||
|
||||
def __extract_file(filepath: str, data_dir: str):
|
||||
try:
|
||||
with tarfile.open(filepath) as tar:
|
||||
safe_extract(tar, data_dir)
|
||||
except Exception:
|
||||
logging.info("Not extracting. Maybe already there?")
|
||||
|
||||
|
||||
def __process_transcript(file_path: str, dst_folder: str):
|
||||
"""
|
||||
Converts flac files to wav from a given transcript, capturing the metadata.
|
||||
Args:
|
||||
file_path: path to a source transcript with flac sources
|
||||
dst_folder: path where wav files will be stored
|
||||
Returns:
|
||||
a list of metadata entries for processed files.
|
||||
"""
|
||||
Transformer = _load_sox_transformer()
|
||||
entries = []
|
||||
root = os.path.dirname(file_path)
|
||||
with open(file_path, encoding="utf-8") as fin:
|
||||
for line in fin:
|
||||
id, text = line[: line.index(" ")], line[line.index(" ") + 1 :]
|
||||
transcript_text = text.lower().strip()
|
||||
|
||||
# Convert FLAC file to WAV
|
||||
flac_file = os.path.join(root, id + ".flac")
|
||||
wav_file = os.path.join(dst_folder, id + ".wav")
|
||||
if not os.path.exists(wav_file):
|
||||
Transformer().build(flac_file, wav_file)
|
||||
# check duration
|
||||
duration = subprocess.check_output(["soxi", "-D", wav_file])
|
||||
|
||||
entry = {}
|
||||
entry["audio_filepath"] = os.path.abspath(wav_file)
|
||||
entry["duration"] = float(duration)
|
||||
entry["text"] = transcript_text
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def __process_data(data_folder: str, dst_folder: str, manifest_file: str, num_workers: int):
|
||||
"""
|
||||
Converts flac to wav and build manifests's json
|
||||
Args:
|
||||
data_folder: source with flac files
|
||||
dst_folder: where wav files will be stored
|
||||
manifest_file: where to store manifest
|
||||
num_workers: number of parallel workers processing files
|
||||
Returns:
|
||||
"""
|
||||
|
||||
if not os.path.exists(dst_folder):
|
||||
os.makedirs(dst_folder)
|
||||
|
||||
files = []
|
||||
entries = []
|
||||
|
||||
for root, dirnames, filenames in os.walk(data_folder):
|
||||
for filename in fnmatch.filter(filenames, "*.trans.txt"):
|
||||
files.append(os.path.join(root, filename))
|
||||
|
||||
with multiprocessing.Pool(num_workers) as p:
|
||||
processing_func = functools.partial(__process_transcript, dst_folder=dst_folder)
|
||||
results = p.imap(processing_func, files)
|
||||
for result in tqdm(results, total=len(files)):
|
||||
entries.extend(result)
|
||||
|
||||
with open(manifest_file, "w") as fout:
|
||||
for m in entries:
|
||||
fout.write(json.dumps(m) + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
data_root = args.data_root
|
||||
data_sets = args.data_sets
|
||||
num_workers = args.num_workers
|
||||
|
||||
if args.log:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
if data_sets == "ALL":
|
||||
data_sets = "dev_clean,dev_other,train_clean_100,train_clean_360,train_other_500,test_clean,test_other"
|
||||
if data_sets == "mini":
|
||||
data_sets = "dev_clean_2,train_clean_5"
|
||||
for data_set in data_sets.split(","):
|
||||
logging.info("\n\nWorking on: {0}".format(data_set))
|
||||
filepath = os.path.join(data_root, data_set + ".tar.gz")
|
||||
logging.info("Getting {0}".format(data_set))
|
||||
__maybe_download_file(filepath, data_set.upper())
|
||||
logging.info("Extracting {0}".format(data_set))
|
||||
__extract_file(filepath, data_root)
|
||||
logging.info("Processing {0}".format(data_set))
|
||||
__process_data(
|
||||
os.path.join(
|
||||
os.path.join(data_root, "LibriSpeech"),
|
||||
data_set.replace("_", "-"),
|
||||
),
|
||||
os.path.join(
|
||||
os.path.join(data_root, "LibriSpeech"),
|
||||
data_set.replace("_", "-"),
|
||||
)
|
||||
+ "-processed",
|
||||
os.path.join(data_root, data_set + ".json"),
|
||||
num_workers=num_workers,
|
||||
)
|
||||
logging.info("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,165 @@
|
||||
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# USAGE: python get_openslr_rir_data.py --data_root=<where to put data>
|
||||
# Data is downloaded from OpenSLR's "Room Impulse Response and Noise Database"
|
||||
# RIRs in multichannel files are separated into single channel files and
|
||||
# a json file that can be used as in input to NeMo is created
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from shutil import copy, move
|
||||
from zipfile import ZipFile
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
parser = argparse.ArgumentParser(description="OpenSLR RIR Data download and process")
|
||||
parser.add_argument("--data_root", required=True, default=None, type=str)
|
||||
args = parser.parse_args()
|
||||
|
||||
URLS = {
|
||||
"SLR28": ("http://www.openslr.org/resources/28/rirs_noises.zip"),
|
||||
}
|
||||
|
||||
|
||||
def __retrieve_with_progress(source: str, filename: str):
|
||||
"""
|
||||
Downloads source to destination
|
||||
Displays progress bar
|
||||
Args:
|
||||
source: url of resource
|
||||
destination: local filepath
|
||||
Returns:
|
||||
"""
|
||||
with open(filename, "wb") as f:
|
||||
response = urllib.request.urlopen(source)
|
||||
total = response.length
|
||||
|
||||
if total is None:
|
||||
f.write(response.content)
|
||||
else:
|
||||
with tqdm(total=total, unit="B", unit_scale=True, unit_divisor=1024) as pbar:
|
||||
for data in response:
|
||||
f.write(data)
|
||||
pbar.update(len(data))
|
||||
|
||||
|
||||
def __maybe_download_file(destination: str, source: str):
|
||||
"""
|
||||
Downloads source to destination if it doesn't exist.
|
||||
If exists, skips download
|
||||
Args:
|
||||
destination: local filepath
|
||||
source: url of resource
|
||||
Returns:
|
||||
"""
|
||||
source = URLS[source]
|
||||
if not os.path.exists(destination):
|
||||
logging.info("{0} does not exist. Downloading ...".format(destination))
|
||||
__retrieve_with_progress(source, filename=destination + ".tmp")
|
||||
os.rename(destination + ".tmp", destination)
|
||||
logging.info("Downloaded {0}.".format(destination))
|
||||
else:
|
||||
logging.info("Destination {0} exists. Skipping.".format(destination))
|
||||
return destination
|
||||
|
||||
|
||||
def __extract_file(filepath: str, data_dir: str):
|
||||
try:
|
||||
with ZipFile(filepath, "r") as zipObj:
|
||||
zipObj.extractall(data_dir)
|
||||
except Exception:
|
||||
logging.info("Not extracting. Maybe already there?")
|
||||
|
||||
|
||||
def __process_data(data_folder: str, dst_folder: str, manifest_file: str):
|
||||
"""
|
||||
Converts flac to wav and build manifests's json
|
||||
Args:
|
||||
data_folder: source with flac files
|
||||
dst_folder: where wav files will be stored
|
||||
manifest_file: where to store manifest
|
||||
Returns:
|
||||
"""
|
||||
if not os.path.exists(dst_folder):
|
||||
os.makedirs(dst_folder)
|
||||
|
||||
real_rir_list = os.path.join(data_folder, "RIRS_NOISES", "real_rirs_isotropic_noises", "rir_list")
|
||||
rirfiles = []
|
||||
with open(real_rir_list, "r") as rir_f:
|
||||
for line in rir_f:
|
||||
rirfiles.append(os.path.join(data_folder, line.rstrip().split(" ")[4]))
|
||||
|
||||
real_rir_folder = os.path.join(dst_folder, "real_rirs")
|
||||
if not os.path.exists(real_rir_folder):
|
||||
os.makedirs(real_rir_folder)
|
||||
# split multi-channel rir files to single channel
|
||||
for rir_f in rirfiles:
|
||||
n_chans = int(subprocess.check_output(["soxi", "-c", rir_f]))
|
||||
if n_chans == 1:
|
||||
copy(rir_f, real_rir_folder)
|
||||
else:
|
||||
for chan in range(1, n_chans + 1):
|
||||
chan_file_name = os.path.join(
|
||||
real_rir_folder,
|
||||
os.path.splitext(os.path.basename(rir_f))[0] + "-" + str(chan) + ".wav",
|
||||
)
|
||||
_ = subprocess.check_output(["sox", rir_f, chan_file_name, "remix", str(chan)])
|
||||
|
||||
# move simulated rirs to processed
|
||||
if not os.path.exists(os.path.join(dst_folder, "simulated_rirs")):
|
||||
move(os.path.join(data_folder, "RIRS_NOISES", "simulated_rirs"), dst_folder)
|
||||
|
||||
os.chdir(dst_folder)
|
||||
all_rirs = glob.glob("**/*.wav", recursive=True)
|
||||
with open(manifest_file, "w") as man_f:
|
||||
entry = {}
|
||||
for rir in all_rirs:
|
||||
rir_file = os.path.join(dst_folder, rir)
|
||||
duration = subprocess.check_output(["soxi", "-D", rir_file])
|
||||
entry["audio_filepath"] = rir_file
|
||||
entry["duration"] = float(duration)
|
||||
entry["offset"] = 0
|
||||
entry["text"] = "_"
|
||||
man_f.write(json.dumps(entry) + "\n")
|
||||
|
||||
print("Done!")
|
||||
|
||||
|
||||
def main():
|
||||
data_root = os.path.abspath(args.data_root)
|
||||
data_set = "slr28"
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
logging.info("\n\nWorking on: {0}".format(data_set))
|
||||
filepath = os.path.join(data_root, data_set + ".zip")
|
||||
logging.info("Getting {0}".format(data_set))
|
||||
__maybe_download_file(filepath, data_set.upper())
|
||||
logging.info("Extracting {0}".format(data_set))
|
||||
__extract_file(filepath, data_root)
|
||||
logging.info("Processing {0}".format(data_set))
|
||||
__process_data(
|
||||
data_root,
|
||||
os.path.join(os.path.join(data_root, "processed")),
|
||||
os.path.join(os.path.join(data_root, "processed", "rir.json")),
|
||||
)
|
||||
logging.info("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,135 @@
|
||||
# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Convert kaldi data folder to manifest.json")
|
||||
parser.add_argument(
|
||||
"--data_dir",
|
||||
required=True,
|
||||
type=str,
|
||||
help="data in kaldi format",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest",
|
||||
required=True,
|
||||
type=str,
|
||||
help="path to store the manifest file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--with_aux_data",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="whether to include auxiliary data in the manifest",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
kaldi_folder = args.data_dir
|
||||
required_data = {
|
||||
"audio_filepath": os.path.join(kaldi_folder, "wav.scp"),
|
||||
"duration": os.path.join(kaldi_folder, "segments"),
|
||||
"text": os.path.join(kaldi_folder, "text"),
|
||||
}
|
||||
aux_data = {
|
||||
"speaker": os.path.join(kaldi_folder, "utt2spk"),
|
||||
"gender": os.path.join(kaldi_folder, "utt2gender"),
|
||||
}
|
||||
output_names = list(required_data.keys())
|
||||
|
||||
# check if required files exist
|
||||
for name, file in required_data.items():
|
||||
if not os.path.exists(file):
|
||||
raise ValueError(f"{os.path.basename(file)} is not in {kaldi_folder}.")
|
||||
|
||||
# read wav.scp
|
||||
wavscp = pd.read_csv(required_data["audio_filepath"], sep=" ", header=None)
|
||||
if wavscp.shape[1] > 2:
|
||||
logging.warning(
|
||||
f"""More than two columns in 'wav.scp': {wavscp.shape[1]}.
|
||||
Maybe it contains pipes? Pipe processing can be slow at runtime."""
|
||||
)
|
||||
wavscp = pd.read_csv(
|
||||
required_data["audio_filepath"],
|
||||
sep="^([^ ]+) ",
|
||||
engine="python",
|
||||
header=None,
|
||||
usecols=[1, 2],
|
||||
names=["wav_label", "audio_filepath"],
|
||||
)
|
||||
else:
|
||||
wavscp = wavscp.rename(columns={0: "wav_label", 1: "audio_filepath"})
|
||||
|
||||
# read text
|
||||
text = pd.read_csv(
|
||||
required_data["text"],
|
||||
sep="^([^ ]+) ",
|
||||
engine="python",
|
||||
header=None,
|
||||
usecols=[1, 2],
|
||||
names=["label", "text"],
|
||||
)
|
||||
|
||||
# read segments
|
||||
segments = pd.read_csv(
|
||||
required_data["duration"],
|
||||
sep=" ",
|
||||
header=None,
|
||||
names=["label", "wav_label", "offset", "end"],
|
||||
)
|
||||
# add offset if needed
|
||||
if len(segments.offset) > len(segments.offset[segments.offset == 0.0]):
|
||||
logging.info("Adding offset field.")
|
||||
output_names.insert(2, "offset")
|
||||
segments["duration"] = (segments.end - segments.offset).round(decimals=3)
|
||||
|
||||
# merge data
|
||||
wav_segments_text = pd.merge(
|
||||
pd.merge(segments, wavscp, how="inner", on="wav_label"),
|
||||
text,
|
||||
how="inner",
|
||||
on="label",
|
||||
)
|
||||
|
||||
if args.with_aux_data:
|
||||
# check if auxiliary data is present
|
||||
for name, aux_file in aux_data.items():
|
||||
if os.path.exists(aux_file):
|
||||
logging.info(f"Adding info from '{os.path.basename(aux_file)}'.")
|
||||
wav_segments_text = pd.merge(
|
||||
wav_segments_text,
|
||||
pd.read_csv(aux_file, sep=" ", header=None, names=["label", name]),
|
||||
how="left",
|
||||
on="label",
|
||||
)
|
||||
output_names.append(name)
|
||||
else:
|
||||
logging.info(f"'{os.path.basename(aux_file)}' does not exist. Skipping ...")
|
||||
|
||||
# write data to .json
|
||||
entries = wav_segments_text[output_names].to_dict(orient="records")
|
||||
with open(args.manifest, "w", encoding="utf-8") as fout:
|
||||
for m in entries:
|
||||
fout.write(json.dumps(m, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,109 @@
|
||||
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# USAGE: python process_aishell2_data.py
|
||||
# --audio_folder=<source data>
|
||||
# --dest_folder=<where to store the results>
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
parser = argparse.ArgumentParser(description="Processing Aishell2 Data")
|
||||
parser.add_argument("--audio_folder", default=None, type=str, required=True, help="Audio (wav) data directory.")
|
||||
parser.add_argument("--dest_folder", default=None, type=str, required=True, help="Destination directory.")
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def __process_data(data_folder: str, dst_folder: str):
|
||||
"""
|
||||
To generate manifest
|
||||
Args:
|
||||
data_folder: source with wav files
|
||||
dst_folder: where manifest files will be stored
|
||||
Returns:
|
||||
"""
|
||||
if not os.path.exists(dst_folder):
|
||||
os.makedirs(dst_folder)
|
||||
data_type = ['dev', 'test', 'train']
|
||||
for data in data_type:
|
||||
dst_file = os.path.join(dst_folder, data + ".json")
|
||||
uttrances = []
|
||||
wav_dir = os.path.join(data_folder, "wav", data)
|
||||
transcript_file = os.path.join(data_folder, "transcript", data, "trans.txt")
|
||||
trans_text = {}
|
||||
with open(transcript_file, "r", encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip().split()
|
||||
utterance_id, text = line[0], " ".join(line[1:])
|
||||
trans_text[utterance_id] = text.upper()
|
||||
session_list = os.listdir(wav_dir)
|
||||
for sessions in session_list:
|
||||
cur_dir = os.path.join(wav_dir, sessions)
|
||||
for wavs in os.listdir(cur_dir):
|
||||
audio_id = wavs.strip(".wav")
|
||||
audio_filepath = os.path.abspath(os.path.join(cur_dir, wavs))
|
||||
duration = subprocess.check_output(["soxi", "-D", audio_filepath])
|
||||
duration = float(duration)
|
||||
text = trans_text[audio_id]
|
||||
uttrances.append(
|
||||
json.dumps(
|
||||
{"audio_filepath": audio_filepath, "duration": duration, "text": text}, ensure_ascii=False
|
||||
)
|
||||
)
|
||||
with open(dst_file, "w") as f:
|
||||
for line in uttrances:
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
def __get_vocab(data_folder: str, des_dir: str):
|
||||
"""
|
||||
To generate the vocabulary file
|
||||
Args:
|
||||
data_folder: source with the transcript file
|
||||
dst_folder: where the file will be stored
|
||||
Returns:
|
||||
"""
|
||||
if not os.path.exists(des_dir):
|
||||
os.makedirs(des_dir)
|
||||
trans_file = os.path.join(data_folder, "transcript", "train", "trans.txt")
|
||||
vocab_dict = {}
|
||||
with open(trans_file, "r", encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip().split()
|
||||
text = " ".join(line[1:])
|
||||
for i in text.upper():
|
||||
if i in vocab_dict:
|
||||
vocab_dict[i] += 1
|
||||
else:
|
||||
vocab_dict[i] = 1
|
||||
vocab_dict = sorted(vocab_dict.items(), key=lambda k: k[1], reverse=True)
|
||||
vocab = os.path.join(des_dir, "vocab.txt")
|
||||
vocab = open(vocab, "w", encoding='utf-8')
|
||||
for k in vocab_dict:
|
||||
vocab.write(k[0] + "\n")
|
||||
vocab.close()
|
||||
|
||||
|
||||
def main():
|
||||
source_data = args.audio_folder
|
||||
des_dir = args.dest_folder
|
||||
print("begin to process data...")
|
||||
__process_data(source_data, des_dir)
|
||||
__get_vocab(source_data, des_dir)
|
||||
print("finish all!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,89 @@
|
||||
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import librosa
|
||||
|
||||
parser = argparse.ArgumentParser(description="AN4 dataset download and processing")
|
||||
parser.add_argument("--data_root", required=True, default=None, type=str)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def build_manifest(data_root, transcripts_path, manifest_path, wav_path):
|
||||
with open(transcripts_path, 'r') as fin:
|
||||
with open(manifest_path, 'w') as fout:
|
||||
for line in fin:
|
||||
# Lines look like this:
|
||||
# <s> transcript </s> (fileID)
|
||||
transcript = line[: line.find('(') - 1].lower()
|
||||
transcript = transcript.replace('<s>', '').replace('</s>', '')
|
||||
transcript = transcript.strip()
|
||||
|
||||
file_id = line[line.find('(') + 1 : -2] # e.g. "cen4-fash-b"
|
||||
audio_path = os.path.join(
|
||||
data_root,
|
||||
wav_path,
|
||||
file_id[file_id.find('-') + 1 : file_id.rfind('-')],
|
||||
file_id + '.wav',
|
||||
)
|
||||
|
||||
duration = librosa.core.get_duration(filename=audio_path)
|
||||
|
||||
# Write the metadata to the manifest
|
||||
metadata = {
|
||||
"audio_filepath": audio_path,
|
||||
"duration": duration,
|
||||
"text": transcript,
|
||||
}
|
||||
json.dump(metadata, fout)
|
||||
fout.write('\n')
|
||||
|
||||
|
||||
def main():
|
||||
data_root = os.path.abspath(args.data_root)
|
||||
|
||||
# Convert from .sph to .wav
|
||||
logging.info("Converting audio files to .wav...")
|
||||
sph_list = glob.glob(os.path.join(data_root, 'an4/**/*.sph'), recursive=True)
|
||||
for sph_path in sph_list:
|
||||
wav_path = sph_path[:-4] + '.wav'
|
||||
cmd = ['sox', sph_path, wav_path]
|
||||
subprocess.run(cmd)
|
||||
logging.info("Finished conversion.")
|
||||
|
||||
# Build manifests
|
||||
logging.info("Building training manifest...")
|
||||
train_transcripts = os.path.join(data_root, 'an4/etc/an4_train.transcription')
|
||||
train_manifest = os.path.join(data_root, 'an4/train_manifest.json')
|
||||
train_wavs = os.path.join(data_root, 'an4/wav/an4_clstk')
|
||||
build_manifest(data_root, train_transcripts, train_manifest, train_wavs)
|
||||
logging.info("Training manifests created.")
|
||||
|
||||
logging.info("Building test manifest...")
|
||||
test_transcripts = os.path.join(data_root, 'an4/etc/an4_test.transcription')
|
||||
test_manifest = os.path.join(data_root, 'an4/test_manifest.json')
|
||||
test_wavs = os.path.join(data_root, 'an4/wav/an4test_clstk')
|
||||
build_manifest(data_root, test_transcripts, test_manifest, test_wavs)
|
||||
logging.info("Test manifest created.")
|
||||
|
||||
logging.info("Done with AN4 processing!")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,434 @@
|
||||
# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# USAGE:
|
||||
# python process_fisher_data.py \
|
||||
# --audio_root=<audio (.wav) directory>
|
||||
# --transcript_root=<LDC Fisher dataset directory> \
|
||||
# --dest_root=<destination directory> \
|
||||
# --data_sets=LDC2004S13-Part1,LDC2005S13-Part2 \
|
||||
# --remove_noises
|
||||
#
|
||||
# Matches Fisher dataset transcripts to the corresponding audio file (.wav),
|
||||
# and slices them into min_slice_duration segments with one speaker.
|
||||
# Also performs some other processing on transcripts.
|
||||
#
|
||||
# Heavily derived from Patter's Fisher processing script.
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from math import ceil, floor
|
||||
|
||||
import numpy as np
|
||||
import scipy.io.wavfile as wavfile
|
||||
from tqdm import tqdm
|
||||
|
||||
parser = argparse.ArgumentParser(description="Fisher Data Processing")
|
||||
parser.add_argument(
|
||||
"--audio_root",
|
||||
default=None,
|
||||
type=str,
|
||||
required=True,
|
||||
help="The path to the root of the audio (wav) data folder.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--transcript_root",
|
||||
default=None,
|
||||
type=str,
|
||||
required=True,
|
||||
help="The path to the root of the transcript data folder.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dest_root",
|
||||
default=None,
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to the destination root directory.",
|
||||
)
|
||||
|
||||
# Optional arguments
|
||||
parser.add_argument(
|
||||
"--min_slice_duration",
|
||||
default=10.0,
|
||||
type=float,
|
||||
help="Minimum audio slice duration after processing.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep_low_conf",
|
||||
action="store_true",
|
||||
help="Keep all utterances with low confidence transcripts",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--remove_noises",
|
||||
action="store_true",
|
||||
help="Removes transcripted noises such as [laughter].",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--noises_to_emoji",
|
||||
action="store_true",
|
||||
help="Converts transcripts for noises to an emoji character.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Total number of files before segmenting, and train/val/test splits
|
||||
NUM_FILES = 5850 + 5849
|
||||
TRAIN_END_IDX = int(NUM_FILES * 0.8)
|
||||
VAL_END_IDX = int(NUM_FILES * 0.9)
|
||||
|
||||
# Known transcription errors and their fixes (from Mozilla)
|
||||
TRANSCRIPT_BUGS = {
|
||||
"fe_03_00265-B-3353-3381": "correct",
|
||||
"fe_03_00991-B-52739-52829": "that's one of those",
|
||||
"fe_03_10282-A-34442-34484.wav": "they don't want",
|
||||
"fe_03_10677-B-10104-10641": "uh my mine yeah the german shepherd "
|
||||
+ "pitbull mix he snores almost as loud "
|
||||
+ "as i do",
|
||||
"fe_03_00027-B-39380-39405": None,
|
||||
"fe_03_11487-B-3109-23406": None,
|
||||
"fe_03_01326-A-30742-30793": None,
|
||||
}
|
||||
|
||||
TRANSCRIPT_NUMBERS = {
|
||||
"401k": "four o one k",
|
||||
"f16": "f sixteen",
|
||||
"m16": "m sixteen",
|
||||
"ak47": "a k forty seven",
|
||||
"v8": "v eight",
|
||||
"y2k": "y two k",
|
||||
"mp3": "m p three",
|
||||
"vh1": "v h one",
|
||||
"90210": "nine o two one o",
|
||||
"espn2": "e s p n two",
|
||||
"u2": "u two",
|
||||
"dc3s": "d c threes",
|
||||
"book 2": "book two",
|
||||
"s2b": "s two b",
|
||||
"3d": "three d",
|
||||
}
|
||||
|
||||
TAG_MAP = {
|
||||
"[laughter]": "🤣",
|
||||
"[laugh]": "🤣",
|
||||
"[noise]": "😕",
|
||||
"[sigh]": "😕",
|
||||
"[cough]": "😕",
|
||||
"[mn]": "😕",
|
||||
"[breath]": "😕",
|
||||
"[lipsmack]": "😕",
|
||||
"[[skip]]": "",
|
||||
"[pause]": "",
|
||||
"[sneeze]": "😕",
|
||||
}
|
||||
|
||||
|
||||
def __write_sample(dest, file_id, count, file_count, sample_rate, audio, duration, transcript):
|
||||
"""
|
||||
Writes one slice to the given target directory.
|
||||
Args:
|
||||
dest: the destination directory
|
||||
file_id: name of the transcript/audio file for this block
|
||||
count: the count of segments in the file so far
|
||||
file_count: the total number of filse processed so far
|
||||
sample rate: sample rate of the audio data
|
||||
audio: audio data of the current sample
|
||||
duration: audio duration of the current sample
|
||||
transcript: transcript of the current sample
|
||||
"""
|
||||
partition = __partition_name(file_count)
|
||||
audio_path = os.path.join(dest, partition, f"{file_id}_{count:03}.wav")
|
||||
|
||||
# Write audio
|
||||
wavfile.write(audio_path, sample_rate, audio)
|
||||
|
||||
# Write transcript info
|
||||
transcript = {
|
||||
"audio_filepath": audio_path,
|
||||
"duration": duration,
|
||||
"text": transcript,
|
||||
}
|
||||
|
||||
# Append to manifest
|
||||
manifest_path = os.path.join(dest, f"manifest_{partition}.json")
|
||||
with open(manifest_path, 'a') as f:
|
||||
json.dump(transcript, f)
|
||||
f.write('\n')
|
||||
|
||||
|
||||
def __normalize(utt):
|
||||
replace_table = str.maketrans(dict.fromkeys('()*;:"!&{},.-?'))
|
||||
utt = (
|
||||
utt.lower()
|
||||
.replace('[uh]', 'uh')
|
||||
.replace('[um]', 'um')
|
||||
.replace('<noise>', '[noise]')
|
||||
.replace('<spoken_noise>', '[vocalized-noise]')
|
||||
.replace('.period', 'period')
|
||||
.replace('.dot', 'dot')
|
||||
.replace('-hyphen', 'hyphen')
|
||||
.replace('._', ' ')
|
||||
.translate(replace_table)
|
||||
)
|
||||
utt = re.sub(r"'([a-z]+)'", r'\1', utt) # Unquote quoted words
|
||||
return utt
|
||||
|
||||
|
||||
def __process_utterance(file_id, trans_path, line, keep_low_conf, rem_noises, emojify):
|
||||
"""
|
||||
Processes one utterance (one line of a transcript).
|
||||
Args:
|
||||
file_id: the ID of the transcript file
|
||||
trans_path: transcript path
|
||||
line: one line in the transcript file
|
||||
keep_low_conf: whether to keep low confidence lines
|
||||
rem_noises: whether to remove noise symbols
|
||||
emojify: whether to convert noise symbols to emoji, lower precedence
|
||||
"""
|
||||
# Check for lines to skip (comments, empty, low confidence)
|
||||
if line.startswith('#') or not line.strip() or (not keep_low_conf and '((' in line):
|
||||
return None, None, None, None
|
||||
|
||||
# Data and sanity checks
|
||||
line = line.split()
|
||||
|
||||
t_start, t_end = float(line[0]), float(line[1])
|
||||
if (t_start < 0) or (t_end < t_start):
|
||||
print(f"Invalid time: {t_start} to {t_end} in {trans_path}")
|
||||
return None, None, None, None
|
||||
|
||||
channel = line[2]
|
||||
idx = 0 if line[2] == 'A:' else 1
|
||||
|
||||
if channel not in ('A:', 'B:'):
|
||||
print(f"Could not read channel info ({channel}) in {trans_path}")
|
||||
return None, None, None, None
|
||||
|
||||
# Replacements as necessary
|
||||
line_id = '-'.join([file_id, channel[0], str(t_start * 10), str(t_end * 10)])
|
||||
|
||||
content = TRANSCRIPT_BUGS.get(line_id, ' '.join(line[3:]))
|
||||
|
||||
if content is None:
|
||||
return None, None, None, None
|
||||
|
||||
for tag, newtag in TRANSCRIPT_NUMBERS.items():
|
||||
content = content.replace(tag, newtag)
|
||||
|
||||
content = __normalize(content)
|
||||
|
||||
if rem_noises:
|
||||
for k, _ in TAG_MAP.items():
|
||||
content = content.replace(k, '')
|
||||
elif emojify:
|
||||
for k, v in TAG_MAP.items():
|
||||
content = content.replace(k, v)
|
||||
|
||||
return t_start, t_end, idx, content
|
||||
|
||||
|
||||
def __process_one_file(
|
||||
trans_path,
|
||||
sample_rate,
|
||||
audio_data,
|
||||
file_id,
|
||||
dst_root,
|
||||
min_slice_duration,
|
||||
file_count,
|
||||
keep_low_conf,
|
||||
rem_noises,
|
||||
emojify,
|
||||
):
|
||||
"""
|
||||
Creates one block of audio slices and their corresponding transcripts.
|
||||
Args:
|
||||
trans_path: filepath to transcript
|
||||
sample_rate: sample rate of the audio
|
||||
audio_data: numpy array of shape [samples, channels]
|
||||
file_id: identifying label, e.g. 'fe_03_01102'
|
||||
dst_root: path to destination directory
|
||||
min_slice_duration: min number of seconds for an audio slice
|
||||
file_count: total number of files processed so far
|
||||
keep_low_conf: keep utterances with low-confidence transcripts
|
||||
rem_noises: remove noise symbols
|
||||
emojify: convert noise symbols into emoji characters
|
||||
"""
|
||||
count = 0
|
||||
|
||||
with open(trans_path, encoding="utf-8") as fin:
|
||||
fin.readline() # Comment w/ corresponding sph filename
|
||||
fin.readline() # Comment about transcriber
|
||||
|
||||
transcript_buffers = ['', ''] # [A buffer, B buffer]
|
||||
audio_buffers = [[], []]
|
||||
buffer_durations = [0.0, 0.0]
|
||||
|
||||
for line in fin:
|
||||
t_start, t_end, idx, content = __process_utterance(
|
||||
file_id, trans_path, line, keep_low_conf, rem_noises, emojify
|
||||
)
|
||||
|
||||
if content is None or not content:
|
||||
continue
|
||||
|
||||
duration = t_end - t_start
|
||||
|
||||
# Append utterance to buffer
|
||||
transcript_buffers[idx] += content
|
||||
audio_buffers[idx].append(
|
||||
audio_data[
|
||||
floor(t_start * sample_rate) : ceil(t_end * sample_rate),
|
||||
idx,
|
||||
]
|
||||
)
|
||||
buffer_durations[idx] += duration
|
||||
|
||||
if buffer_durations[idx] < min_slice_duration:
|
||||
transcript_buffers[idx] += ' '
|
||||
else:
|
||||
# Write out segment and transcript
|
||||
count += 1
|
||||
__write_sample(
|
||||
dst_root,
|
||||
file_id,
|
||||
count,
|
||||
file_count,
|
||||
sample_rate,
|
||||
np.concatenate(audio_buffers[idx], axis=0),
|
||||
buffer_durations[idx],
|
||||
transcript_buffers[idx],
|
||||
)
|
||||
|
||||
# Clear buffers
|
||||
transcript_buffers[idx] = ''
|
||||
audio_buffers[idx] = []
|
||||
buffer_durations[idx] = 0.0
|
||||
|
||||
# Note: We drop any shorter "scraps" at the end of the file, if
|
||||
# they end up shorter than min_slice_duration.
|
||||
|
||||
|
||||
def __partition_name(file_count):
|
||||
if file_count >= VAL_END_IDX:
|
||||
return "test"
|
||||
elif file_count >= TRAIN_END_IDX:
|
||||
return "val"
|
||||
else:
|
||||
return "train"
|
||||
|
||||
|
||||
def __process_data(
|
||||
audio_root,
|
||||
transcript_root,
|
||||
dst_root,
|
||||
min_slice_duration,
|
||||
file_count,
|
||||
keep_low_conf,
|
||||
rem_noises,
|
||||
emojify,
|
||||
):
|
||||
"""
|
||||
Converts Fisher wav files to numpy arrays, segments audio and transcripts.
|
||||
Args:
|
||||
audio_root: source directory with the wav files
|
||||
transcript_root: source directory with the transcript files
|
||||
(can be the same as audio_root)
|
||||
dst_root: where the processed and segmented files will be stored
|
||||
min_slice_duration: minimum number of seconds for a slice of output
|
||||
file_count: total number of files processed so far
|
||||
keep_low_conf: whether or not to keep low confidence transcriptions
|
||||
rem_noises: whether to remove noise symbols
|
||||
emojify: whether to convert noise symbols to emoji, lower precedence
|
||||
Assumes:
|
||||
1. There is exactly one transcripts directory in data_folder
|
||||
2. Audio files are all: <audio_root>/audio-wav/fe_03_xxxxx.wav
|
||||
"""
|
||||
transcript_list = glob.glob(os.path.join(transcript_root, "fe_03_p*_tran*", "data", "trans", "*", "*.txt"))
|
||||
print("Found {} transcripts.".format(len(transcript_list)))
|
||||
|
||||
count = file_count
|
||||
|
||||
# Grab audio file associated with each transcript, and slice
|
||||
for trans_path in tqdm(transcript_list, desc="Matching and segmenting"):
|
||||
file_id, _ = os.path.splitext(os.path.basename(trans_path))
|
||||
audio_path = os.path.join(audio_root, "audio_wav", file_id + ".wav")
|
||||
|
||||
sample_rate, audio_data = wavfile.read(audio_path)
|
||||
|
||||
# Create a set of segments (a block) for each file
|
||||
__process_one_file(
|
||||
trans_path,
|
||||
sample_rate,
|
||||
audio_data,
|
||||
file_id,
|
||||
dst_root,
|
||||
min_slice_duration,
|
||||
count,
|
||||
keep_low_conf,
|
||||
rem_noises,
|
||||
emojify,
|
||||
)
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
|
||||
def main():
|
||||
# Arguments to the script
|
||||
audio_root = args.audio_root
|
||||
transcript_root = args.transcript_root
|
||||
dest_root = args.dest_root
|
||||
|
||||
min_slice_duration = args.min_slice_duration
|
||||
keep_low_conf = args.keep_low_conf
|
||||
rem_noises = args.remove_noises
|
||||
emojify = args.noises_to_emoji
|
||||
|
||||
print(f"Expected number of files to segment: {NUM_FILES}")
|
||||
print("With a 80/10/10 split:")
|
||||
print(f"Number of training files: {TRAIN_END_IDX}")
|
||||
print(f"Number of validation files: {VAL_END_IDX - TRAIN_END_IDX}")
|
||||
print(f"Number of test files: {NUM_FILES - VAL_END_IDX}")
|
||||
|
||||
if not os.path.exists(os.path.join(dest_root, 'train/')):
|
||||
os.makedirs(os.path.join(dest_root, 'train/'))
|
||||
os.makedirs(os.path.join(dest_root, 'val/'))
|
||||
os.makedirs(os.path.join(dest_root, 'test/'))
|
||||
else:
|
||||
# Wipe manifest contents first
|
||||
open(os.path.join(dest_root, "manifest_train.json"), 'w').close()
|
||||
open(os.path.join(dest_root, "manifest_val.json"), 'w').close()
|
||||
open(os.path.join(dest_root, "manifest_test.json"), 'w').close()
|
||||
|
||||
file_count = 0
|
||||
|
||||
for data_set in ['LDC2004S13-Part1', 'LDC2005S13-Part2']:
|
||||
print(f"\n\nWorking on dataset: {data_set}")
|
||||
file_count = __process_data(
|
||||
os.path.join(audio_root, data_set),
|
||||
os.path.join(transcript_root, data_set),
|
||||
dest_root,
|
||||
min_slice_duration,
|
||||
file_count,
|
||||
keep_low_conf,
|
||||
rem_noises,
|
||||
emojify,
|
||||
)
|
||||
|
||||
print(f"Total file count so far: {file_count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,285 @@
|
||||
# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# This script is heavily derived from the Patter HUB5 processing script written
|
||||
# by Ryan Leary
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import namedtuple
|
||||
from math import ceil, floor
|
||||
from operator import attrgetter
|
||||
|
||||
import numpy as np
|
||||
import scipy.io.wavfile as wavfile
|
||||
from tqdm import tqdm
|
||||
|
||||
parser = argparse.ArgumentParser(description="Prepare HUB5 data for training/eval")
|
||||
parser.add_argument(
|
||||
"--data_root",
|
||||
default=None,
|
||||
type=str,
|
||||
required=True,
|
||||
help="The path to the root LDC HUB5 dataset directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dest_root",
|
||||
default=None,
|
||||
type=str,
|
||||
required=True,
|
||||
help="Path to the destination root directory for processed files.",
|
||||
)
|
||||
|
||||
# Optional arguments
|
||||
parser.add_argument(
|
||||
"--min_slice_duration",
|
||||
default=10.0,
|
||||
type=float,
|
||||
help="Minimum audio slice duration after processing.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
StmUtterance = namedtuple(
|
||||
'StmUtterance',
|
||||
[
|
||||
'filename',
|
||||
'channel',
|
||||
'speaker_id',
|
||||
'begin',
|
||||
'end',
|
||||
'label',
|
||||
'transcript',
|
||||
],
|
||||
)
|
||||
STM_LINE_FMT = re.compile(r"^(\w+)\s+(\w+)\s+(\w+)\s+([0-9.]+)\s+([0-9.]+)\s+(<.*>)?\s+(.+)$")
|
||||
|
||||
# Transcription errors and their fixes
|
||||
TRANSCRIPT_BUGS = {"en_4622-B-12079-12187": "KIND OF WEIRD BUT"}
|
||||
|
||||
|
||||
def get_utt_id(segment):
|
||||
"""
|
||||
Gives utterance IDs in a form like: en_4156-a-36558-37113
|
||||
"""
|
||||
return "{}-{}-{}-{}".format(
|
||||
segment.filename,
|
||||
segment.channel,
|
||||
int(segment.begin * 100),
|
||||
int(segment.end * 100),
|
||||
)
|
||||
|
||||
|
||||
def convert_utterances(sph_path, wav_path):
|
||||
"""
|
||||
Converts a sphere audio file to wav.
|
||||
"""
|
||||
cmd = ["sph2pipe", "-f", "wav", "-p", sph_path, wav_path]
|
||||
subprocess.run(cmd)
|
||||
|
||||
|
||||
def create_wavs(data_root, dest_root):
|
||||
"""
|
||||
Converts the English sph files to wav using sph2pipe.
|
||||
"""
|
||||
sph_root = os.path.join(data_root, "hub5e_00", "english")
|
||||
sph_list = glob.glob(os.path.join(sph_root, "*.sph"))
|
||||
|
||||
# Iterate over each sphere file and conver to wav
|
||||
for sph_path in tqdm(sph_list, desc="Converting to wav", unit="file"):
|
||||
sph_name, _ = os.path.splitext(os.path.basename(sph_path))
|
||||
wav_path = os.path.join(dest_root, 'full_audio_wav', sph_name + ".wav")
|
||||
cmd = ["sph2pipe", "-f", "wav", "-p", sph_path, wav_path]
|
||||
subprocess.run(cmd)
|
||||
|
||||
|
||||
def process_transcripts(dataset_root):
|
||||
"""
|
||||
Reads in transcripts for each audio segment and processes them.
|
||||
"""
|
||||
stm_path = os.path.join(
|
||||
dataset_root,
|
||||
"2000_hub5_eng_eval_tr",
|
||||
"reference",
|
||||
"hub5e00.english.000405.stm",
|
||||
)
|
||||
results = []
|
||||
chars = set()
|
||||
|
||||
with open(stm_path, "r") as fh:
|
||||
for line in fh:
|
||||
# lines with ';;' are comments
|
||||
if line.startswith(";;"):
|
||||
continue
|
||||
|
||||
if "IGNORE_TIME_SEGMENT_" in line:
|
||||
continue
|
||||
line = line.replace("<B_ASIDE>", "").replace("<E_ASIDE>", "")
|
||||
line = line.replace("(%HESITATION)", "UH")
|
||||
line = line.replace("-", "")
|
||||
line = line.replace("(%UH)", "UH")
|
||||
line = line.replace("(%AH)", "UH")
|
||||
line = line.replace("(", "").replace(")", "")
|
||||
|
||||
line = line.lower()
|
||||
|
||||
m = STM_LINE_FMT.search(line.strip())
|
||||
utt = StmUtterance(*m.groups())
|
||||
|
||||
# Convert begin/end times to float
|
||||
utt = utt._replace(begin=float(utt.begin))
|
||||
utt = utt._replace(end=float(utt.end))
|
||||
|
||||
# Check for utterance in dict of transcript mistakes
|
||||
transcript_update = TRANSCRIPT_BUGS.get(get_utt_id(utt))
|
||||
if transcript_update is not None:
|
||||
utt = utt._replace(transcript=transcript_update)
|
||||
|
||||
results.append(utt)
|
||||
chars.update(list(utt.transcript))
|
||||
return results, chars
|
||||
|
||||
|
||||
def write_one_segment(dest_root, speaker_id, count, audio, sr, duration, transcript):
|
||||
"""
|
||||
Writes out one segment of audio, and writes its corresponding transcript
|
||||
in the manifest.
|
||||
|
||||
Args:
|
||||
dest_root: the path to the output directory root
|
||||
speaker_id: ID of the speaker, used in file naming
|
||||
count: number of segments from this speaker so far
|
||||
audio: the segment's audio data
|
||||
sr: sample rate of the audio
|
||||
duration: duration of the audio
|
||||
transcript: the corresponding transcript
|
||||
"""
|
||||
audio_path = os.path.join(dest_root, "audio", f"{speaker_id}_{count:03}.wav")
|
||||
|
||||
manifest_path = os.path.join(dest_root, "manifest_hub5.json")
|
||||
|
||||
# Write audio
|
||||
wavfile.write(audio_path, sr, audio)
|
||||
|
||||
# Write transcript
|
||||
transcript = {
|
||||
"audio_filepath": audio_path,
|
||||
"duration": duration,
|
||||
"text": transcript,
|
||||
}
|
||||
with open(manifest_path, 'a') as f:
|
||||
json.dump(transcript, f)
|
||||
f.write('\n')
|
||||
|
||||
|
||||
def segment_audio(info_list, dest_root, min_slice_duration):
|
||||
"""
|
||||
Combines audio into >= min_slice_duration segments of the same speaker,
|
||||
and writes the combined transcripts into a manifest.
|
||||
|
||||
Args:
|
||||
info_list: list of StmUtterance objects with transcript information.
|
||||
dest_root: path to output destination
|
||||
min_slice_duration: min number of seconds per output audio slice
|
||||
"""
|
||||
info_list = sorted(info_list, key=attrgetter('speaker_id', 'begin'))
|
||||
|
||||
prev_id = None # For checking audio concatenation
|
||||
id_count = 0
|
||||
|
||||
sample_rate, audio_data = None, None
|
||||
transcript_buffer = ''
|
||||
audio_buffer = []
|
||||
buffer_duration = 0.0
|
||||
|
||||
# Iterate through utterances to build segments
|
||||
for info in info_list:
|
||||
if info.speaker_id != prev_id:
|
||||
# Scrap the remainder in the buffers and start next segment
|
||||
prev_id = info.speaker_id
|
||||
id_count = 0
|
||||
|
||||
sample_rate, audio_data = wavfile.read(os.path.join(dest_root, 'full_audio_wav', info.filename + '.wav'))
|
||||
transcript_buffer = ''
|
||||
audio_buffer = []
|
||||
buffer_duration = 0.0
|
||||
|
||||
# Append utterance info to buffers
|
||||
transcript_buffer += info.transcript
|
||||
channel = 0 if info.channel.lower() == 'a' else 1
|
||||
audio_buffer.append(
|
||||
audio_data[
|
||||
floor(info.begin * sample_rate) : ceil(info.end * sample_rate),
|
||||
channel,
|
||||
]
|
||||
)
|
||||
buffer_duration += info.end - info.begin
|
||||
|
||||
if buffer_duration < min_slice_duration:
|
||||
transcript_buffer += ' '
|
||||
else:
|
||||
# Write out segment and transcript
|
||||
id_count += 1
|
||||
write_one_segment(
|
||||
dest_root,
|
||||
info.speaker_id,
|
||||
id_count,
|
||||
np.concatenate(audio_buffer, axis=0),
|
||||
sample_rate,
|
||||
buffer_duration,
|
||||
transcript_buffer,
|
||||
)
|
||||
|
||||
transcript_buffer = ''
|
||||
audio_buffer = []
|
||||
buffer_duration = 0.0
|
||||
|
||||
|
||||
def main():
|
||||
data_root = args.data_root
|
||||
dest_root = args.dest_root
|
||||
|
||||
min_slice_duration = args.min_slice_duration
|
||||
|
||||
if not os.path.exists(os.path.join(dest_root, 'full_audio_wav')):
|
||||
os.makedirs(os.path.join(dest_root, 'full_audio_wav'))
|
||||
if not os.path.exists(os.path.join(dest_root, 'audio')):
|
||||
os.makedirs(os.path.join(dest_root, 'audio'))
|
||||
|
||||
# Create/wipe manifest contents
|
||||
open(os.path.join(dest_root, "manifest_hub5.json"), 'w').close()
|
||||
|
||||
# Convert full audio files from .sph to .wav
|
||||
create_wavs(data_root, dest_root)
|
||||
|
||||
# Get each audio transcript from transcript file
|
||||
info_list, chars = process_transcripts(data_root)
|
||||
|
||||
print("Writing out vocab file", file=sys.stderr)
|
||||
with open(os.path.join(dest_root, "vocab.txt"), 'w') as fh:
|
||||
for x in sorted(list(chars)):
|
||||
fh.write(x + "\n")
|
||||
|
||||
# Segment the audio data
|
||||
print("Segmenting audio and writing manifest")
|
||||
segment_audio(info_list, dest_root, min_slice_duration)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,521 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
Usage:
|
||||
|
||||
python process_speech_commands_data.py \
|
||||
--data_root=<absolute path to where the data should be stored> \
|
||||
--data_version=<either 1 or 2, indicating version of the dataset> \
|
||||
--class_split=<either "all" or "sub", indicates whether all 30/35 classes should be used, or the 10+2 split should be used> \
|
||||
--num_processes=<number of processes to use for data preprocessing> \
|
||||
--rebalance \
|
||||
--log
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tarfile
|
||||
import urllib.request
|
||||
from collections import defaultdict
|
||||
from functools import partial
|
||||
from multiprocessing import Pool
|
||||
from typing import Dict, List, Set, Tuple
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
import soundfile
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.utils.tar_utils import safe_extract
|
||||
|
||||
URL_v1 = 'http://download.tensorflow.org/data/speech_commands_v0.01.tar.gz'
|
||||
URL_v2 = 'http://download.tensorflow.org/data/speech_commands_v0.02.tar.gz'
|
||||
|
||||
|
||||
def __maybe_download_file(destination: str, source: str) -> str:
|
||||
"""
|
||||
Downloads source to destination if it doesn't exist.
|
||||
If exists, skips download
|
||||
|
||||
Args:
|
||||
destination: local filepath
|
||||
source: url of resource
|
||||
|
||||
Returns:
|
||||
Local filepath of the downloaded file
|
||||
"""
|
||||
if not os.path.exists(destination):
|
||||
logging.info(f'{destination} does not exist. Downloading ...')
|
||||
urllib.request.urlretrieve(source, filename=destination + '.tmp')
|
||||
os.rename(destination + '.tmp', destination)
|
||||
logging.info(f'Downloaded {destination}.')
|
||||
else:
|
||||
logging.info(f'Destination {destination} exists. Skipping.')
|
||||
return destination
|
||||
|
||||
|
||||
def __extract_all_files(filepath: str, data_dir: str):
|
||||
if not os.path.exists(data_dir):
|
||||
extract_file(filepath, data_dir)
|
||||
else:
|
||||
logging.info(f'Skipping extracting. Data already there {data_dir}')
|
||||
|
||||
|
||||
def extract_file(filepath: str, data_dir: str):
|
||||
try:
|
||||
with tarfile.open(filepath) as tar:
|
||||
safe_extract(tar, data_dir)
|
||||
except Exception:
|
||||
logging.info('Not extracting. Maybe already there?')
|
||||
|
||||
|
||||
def __get_mp_chunksize(dataset_size: int, num_processes: int) -> int:
|
||||
"""
|
||||
Returns the number of chunks to split the dataset into for multiprocessing.
|
||||
|
||||
Args:
|
||||
dataset_size: size of the dataset
|
||||
num_processes: number of processes to use for multiprocessing
|
||||
|
||||
Returns:
|
||||
Number of chunks to split the dataset into for multiprocessing
|
||||
"""
|
||||
chunksize = dataset_size // num_processes
|
||||
return chunksize if chunksize > 0 else 1
|
||||
|
||||
|
||||
def __construct_filepaths(
|
||||
all_files: List[str],
|
||||
valset_uids: Set[str],
|
||||
testset_uids: Set[str],
|
||||
class_split: str,
|
||||
class_subset: List[str],
|
||||
pattern: str,
|
||||
) -> Tuple[Dict[str, int], Dict[str, List[tuple]], List[tuple], List[tuple], List[tuple], List[tuple], List[tuple]]:
|
||||
"""
|
||||
Prepares the filepaths for the dataset.
|
||||
|
||||
Args:
|
||||
all_files: list of all files in the dataset
|
||||
valset_uids: set of uids of files in the validation set
|
||||
testset_uids: set of uids of files in the test set
|
||||
class_split: whether to use all classes as distinct labels, or to use
|
||||
10 classes subset and rest of the classes as noise or background
|
||||
class_subset: list of classes to consider if `class_split` is set to `sub`
|
||||
pattern: regex pattern to match the file names in the dataset
|
||||
"""
|
||||
|
||||
label_count = defaultdict(int)
|
||||
label_filepaths = defaultdict(list)
|
||||
unknown_val_filepaths = []
|
||||
unknown_test_filepaths = []
|
||||
|
||||
train, val, test = [], [], []
|
||||
for entry in all_files:
|
||||
r = re.match(pattern, entry)
|
||||
if r:
|
||||
label, uid = r.group(2), r.group(3)
|
||||
|
||||
if label == '_background_noise_' or label == 'silence':
|
||||
continue
|
||||
|
||||
if class_split == 'sub' and label not in class_subset:
|
||||
label = 'unknown'
|
||||
|
||||
if uid in valset_uids:
|
||||
unknown_val_filepaths.append((label, entry))
|
||||
elif uid in testset_uids:
|
||||
unknown_test_filepaths.append((label, entry))
|
||||
|
||||
if uid not in valset_uids and uid not in testset_uids:
|
||||
label_count[label] += 1
|
||||
label_filepaths[label].append((label, entry))
|
||||
|
||||
if label == 'unknown':
|
||||
continue
|
||||
|
||||
if uid in valset_uids:
|
||||
val.append((label, entry))
|
||||
elif uid in testset_uids:
|
||||
test.append((label, entry))
|
||||
else:
|
||||
train.append((label, entry))
|
||||
|
||||
return {
|
||||
'label_count': label_count,
|
||||
'label_filepaths': label_filepaths,
|
||||
'unknown_val_filepaths': unknown_val_filepaths,
|
||||
'unknown_test_filepaths': unknown_test_filepaths,
|
||||
'train': train,
|
||||
'val': val,
|
||||
'test': test,
|
||||
}
|
||||
|
||||
|
||||
def __construct_silence_set(
|
||||
rng: np.random.RandomState, sampling_rate: int, silence_stride: int, data_folder: str, background_noise: str
|
||||
) -> List[str]:
|
||||
"""
|
||||
Creates silence files given a background noise.
|
||||
|
||||
Args:
|
||||
rng: Random state for random number generator
|
||||
sampling_rate: sampling rate of the audio
|
||||
silence_stride: stride for creating silence files
|
||||
data_folder: folder containing the silence directory
|
||||
background_noise: filepath of the background noise
|
||||
|
||||
Returns:
|
||||
List of filepaths of silence files
|
||||
"""
|
||||
silence_files = []
|
||||
if '.wav' in background_noise:
|
||||
y, sr = librosa.load(background_noise, sr=sampling_rate)
|
||||
|
||||
for i in range(0, len(y) - sampling_rate, silence_stride):
|
||||
file_path = f'silence/{os.path.basename(background_noise)[:-4]}_{i}.wav'
|
||||
y_slice = y[i : i + sampling_rate] * rng.uniform(0.0, 1.0)
|
||||
out_file_path = os.path.join(data_folder, file_path)
|
||||
soundfile.write(out_file_path, y_slice, sr)
|
||||
|
||||
silence_files.append(('silence', out_file_path))
|
||||
|
||||
return silence_files
|
||||
|
||||
|
||||
def __rebalance_files(max_count: int, label_filepath: str) -> Tuple[str, List[str], int]:
|
||||
"""
|
||||
Rebalance the number of samples for a class.
|
||||
|
||||
Args:
|
||||
max_count: maximum number of samples for a class
|
||||
label_filepath: list of filepaths for a class
|
||||
|
||||
Returns:
|
||||
Rebalanced list of filepaths along with the label name and the number of samples
|
||||
"""
|
||||
command, samples = label_filepath
|
||||
filepaths = [sample[1] for sample in samples]
|
||||
|
||||
rng = np.random.RandomState(0)
|
||||
filepaths = np.asarray(filepaths)
|
||||
num_samples = len(filepaths)
|
||||
|
||||
if num_samples < max_count:
|
||||
difference = max_count - num_samples
|
||||
duplication_ids = rng.choice(num_samples, difference, replace=True)
|
||||
filepaths = np.append(filepaths, filepaths[duplication_ids], axis=0)
|
||||
|
||||
return command, filepaths, num_samples
|
||||
|
||||
|
||||
def __prepare_metadata(skip_duration, sample: Tuple[str, str]) -> dict:
|
||||
"""
|
||||
Creates the manifest entry for a file.
|
||||
|
||||
Args:
|
||||
skip_duration: Whether to skip the computation of duration
|
||||
sample: Tuple of label and filepath
|
||||
|
||||
Returns:
|
||||
Manifest entry of the file
|
||||
"""
|
||||
label, audio_path = sample
|
||||
return json.dumps(
|
||||
{
|
||||
'audio_filepath': audio_path,
|
||||
'duration': 0.0 if skip_duration else librosa.core.get_duration(filename=audio_path),
|
||||
'command': label,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def __process_data(
|
||||
data_folder: str,
|
||||
dst_folder: str,
|
||||
num_processes: int = 1,
|
||||
rebalance: bool = False,
|
||||
class_split: str = 'all',
|
||||
skip_duration: bool = False,
|
||||
):
|
||||
"""
|
||||
Processes the data and generates the manifests.
|
||||
|
||||
Args:
|
||||
data_folder: source with wav files and validation / test lists
|
||||
dst_folder: where manifest files will be stored
|
||||
num_processes: number of processes
|
||||
rebalance: rebalance the classes to have same number of samples
|
||||
class_split: whether to use all classes as distinct labels, or to use
|
||||
10 classes subset and rest of the classes as noise or background
|
||||
skip_duration: Bool whether to skip duration computation. Use this only for
|
||||
colab notebooks where knowing duration is not necessary for demonstration
|
||||
"""
|
||||
|
||||
os.makedirs(dst_folder, exist_ok=True)
|
||||
|
||||
# Used for 10 classes + silence + unknown class setup - Only used when class_split is 'sub'
|
||||
class_subset = ['yes', 'no', 'up', 'down', 'left', 'right', 'on', 'off', 'stop', 'go']
|
||||
|
||||
pattern = re.compile(r'(.+\/)?(\w+)\/([^_]+)_.+wav')
|
||||
all_files = glob.glob(os.path.join(data_folder, '*/*wav'))
|
||||
|
||||
# Get files in the validation set
|
||||
valset_uids = set()
|
||||
with open(os.path.join(data_folder, 'validation_list.txt')) as fin:
|
||||
for line in fin:
|
||||
r = re.match(pattern, line)
|
||||
if r:
|
||||
valset_uids.add(r.group(3))
|
||||
|
||||
# Get files in the test set
|
||||
testset_uids = set()
|
||||
with open(os.path.join(data_folder, 'testing_list.txt')) as fin:
|
||||
for line in fin:
|
||||
r = re.match(pattern, line)
|
||||
if r:
|
||||
testset_uids.add(r.group(3))
|
||||
|
||||
logging.info('Validation and test set lists extracted')
|
||||
|
||||
filepath_info = __construct_filepaths(all_files, valset_uids, testset_uids, class_split, class_subset, pattern)
|
||||
label_count = filepath_info['label_count']
|
||||
label_filepaths = filepath_info['label_filepaths']
|
||||
unknown_val_filepaths = filepath_info['unknown_val_filepaths']
|
||||
unknown_test_filepaths = filepath_info['unknown_test_filepaths']
|
||||
train = filepath_info['train']
|
||||
val = filepath_info['val']
|
||||
test = filepath_info['test']
|
||||
|
||||
logging.info('Prepared filepaths for dataset')
|
||||
|
||||
pool = Pool(num_processes)
|
||||
|
||||
# Add silence and unknown class label samples
|
||||
if class_split == 'sub':
|
||||
logging.info('Perforiming 10+2 class subsplit')
|
||||
|
||||
silence_path = os.path.join(data_folder, 'silence')
|
||||
os.makedirs(silence_path, exist_ok=True)
|
||||
|
||||
silence_stride = 1000 # 0.0625 second stride
|
||||
sampling_rate = 16000
|
||||
folder = os.path.join(data_folder, '_background_noise_')
|
||||
|
||||
silence_files = []
|
||||
rng = np.random.RandomState(0)
|
||||
|
||||
background_noise_files = [os.path.join(folder, x) for x in os.listdir(folder)]
|
||||
silence_set_fn = partial(__construct_silence_set, rng, sampling_rate, silence_stride, data_folder)
|
||||
for silence_flist in tqdm(
|
||||
pool.imap(
|
||||
silence_set_fn, background_noise_files, __get_mp_chunksize(len(background_noise_files), num_processes)
|
||||
),
|
||||
total=len(background_noise_files),
|
||||
desc='Constructing silence set',
|
||||
):
|
||||
silence_files.extend(silence_flist)
|
||||
|
||||
rng = np.random.RandomState(0)
|
||||
rng.shuffle(silence_files)
|
||||
logging.info(f'Constructed silence set of {len(silence_files)}')
|
||||
|
||||
# Create the splits
|
||||
rng = np.random.RandomState(0)
|
||||
silence_split = 0.1
|
||||
unknown_split = 0.1
|
||||
|
||||
# train split
|
||||
num_total_samples = sum([label_count[cls] for cls in class_subset])
|
||||
num_silence_samples = int(np.ceil(silence_split * num_total_samples))
|
||||
|
||||
# initialize sample
|
||||
label_count['silence'] = 0
|
||||
label_filepaths['silence'] = []
|
||||
|
||||
for silence_id in range(num_silence_samples):
|
||||
label_count['silence'] += 1
|
||||
label_filepaths['silence'].append(silence_files[silence_id])
|
||||
|
||||
train.extend(label_filepaths['silence'])
|
||||
|
||||
# Update train unknown set
|
||||
unknown_train_samples = label_filepaths['unknown']
|
||||
|
||||
rng.shuffle(unknown_train_samples)
|
||||
unknown_size = int(np.ceil(unknown_split * num_total_samples))
|
||||
|
||||
label_count['unknown'] = unknown_size
|
||||
label_filepaths['unknown'] = unknown_train_samples[:unknown_size]
|
||||
|
||||
train.extend(label_filepaths['unknown'])
|
||||
|
||||
logging.info('Train set prepared')
|
||||
|
||||
# val set silence
|
||||
num_val_samples = len(val)
|
||||
num_silence_samples = int(np.ceil(silence_split * num_val_samples))
|
||||
|
||||
val_idx = label_count['silence'] + 1
|
||||
for silence_id in range(num_silence_samples):
|
||||
val.append(silence_files[val_idx + silence_id])
|
||||
|
||||
# Update val unknown set
|
||||
rng.shuffle(unknown_val_filepaths)
|
||||
unknown_size = int(np.ceil(unknown_split * num_val_samples))
|
||||
|
||||
val.extend(unknown_val_filepaths[:unknown_size])
|
||||
|
||||
logging.info('Validation set prepared')
|
||||
|
||||
# test set silence
|
||||
num_test_samples = len(test)
|
||||
num_silence_samples = int(np.ceil(silence_split * num_test_samples))
|
||||
|
||||
test_idx = val_idx + num_silence_samples + 1
|
||||
for silence_id in range(num_silence_samples):
|
||||
test.append(silence_files[test_idx + silence_id])
|
||||
|
||||
# Update test unknown set
|
||||
rng.shuffle(unknown_test_filepaths)
|
||||
unknown_size = int(np.ceil(unknown_split * num_test_samples))
|
||||
|
||||
test.extend(unknown_test_filepaths[:unknown_size])
|
||||
|
||||
logging.info('Test set prepared')
|
||||
|
||||
max_command = None
|
||||
max_count = -1
|
||||
for command, count in label_count.items():
|
||||
if command == 'unknown':
|
||||
continue
|
||||
|
||||
if count > max_count:
|
||||
max_count = count
|
||||
max_command = command
|
||||
|
||||
if rebalance:
|
||||
logging.info(f'Command with maximum number of samples = {max_command} with {max_count} samples')
|
||||
logging.info(f'Rebalancing dataset by duplicating classes with less than {max_count} samples...')
|
||||
|
||||
rebalance_fn = partial(__rebalance_files, max_count)
|
||||
for command, filepaths, num_samples in tqdm(
|
||||
pool.imap(rebalance_fn, label_filepaths.items(), __get_mp_chunksize(len(label_filepaths), num_processes)),
|
||||
total=len(label_filepaths),
|
||||
desc='Rebalancing dataset',
|
||||
):
|
||||
if num_samples < max_count:
|
||||
logging.info(f'Extended class label {command} from {num_samples} samples to {len(filepaths)} samples')
|
||||
label_filepaths[command] = [(command, filepath) for filepath in filepaths]
|
||||
|
||||
del train
|
||||
train = []
|
||||
for label, samples in label_filepaths.items():
|
||||
train.extend(samples)
|
||||
|
||||
manifests = [
|
||||
('train_manifest.json', train),
|
||||
('validation_manifest.json', val),
|
||||
('test_manifest.json', test),
|
||||
]
|
||||
|
||||
metadata_fn = partial(__prepare_metadata, skip_duration)
|
||||
for manifest_filename, dataset in manifests:
|
||||
num_files = len(dataset)
|
||||
|
||||
logging.info(f'Preparing manifest : {manifest_filename} with #{num_files} files')
|
||||
|
||||
manifest = [
|
||||
metadata
|
||||
for metadata in tqdm(
|
||||
pool.imap(metadata_fn, dataset, __get_mp_chunksize(len(dataset), num_processes)),
|
||||
total=num_files,
|
||||
desc=f'Preparing {manifest_filename}',
|
||||
)
|
||||
]
|
||||
|
||||
with open(os.path.join(dst_folder, manifest_filename), 'w') as fout:
|
||||
for metadata in manifest:
|
||||
fout.write(metadata + '\n')
|
||||
|
||||
logging.info(f'Finished construction of manifest. Path: {os.path.join(dst_folder, manifest_filename)}')
|
||||
|
||||
pool.close()
|
||||
|
||||
if skip_duration:
|
||||
logging.info(
|
||||
f'\n<<NOTE>> Duration computation was skipped for demonstration purposes on Colaboratory.\n'
|
||||
f'In order to replicate paper results and properly perform data augmentation, \n'
|
||||
f'please recompute the manifest file without the `--skip_duration` flag !\n'
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Google Speech Commands Data download and preprocessing')
|
||||
parser.add_argument('--data_root', required=True, help='Root directory for storing data')
|
||||
parser.add_argument(
|
||||
'--data_version',
|
||||
required=True,
|
||||
default=1,
|
||||
type=int,
|
||||
choices=[1, 2],
|
||||
help='Version of the speech commands dataset to download',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--class_split', default='all', choices=['all', 'sub'], help='Whether to consider all classes or only a subset'
|
||||
)
|
||||
parser.add_argument('--num_processes', default=1, type=int, help='Number of processes')
|
||||
parser.add_argument('--rebalance', action='store_true', help='Rebalance the number of samples in each class')
|
||||
parser.add_argument('--skip_duration', action='store_true', help='Skip computing duration of audio files')
|
||||
parser.add_argument('--log', action='store_true', help='Generate logs')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.log:
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
data_root = args.data_root
|
||||
data_set = f'google_speech_recognition_v{args.data_version}'
|
||||
data_folder = os.path.join(data_root, data_set)
|
||||
|
||||
logging.info(f'Working on: {data_set}')
|
||||
|
||||
URL = URL_v1 if args.data_version == 1 else URL_v2
|
||||
|
||||
# Download and extract
|
||||
if not os.path.exists(data_folder):
|
||||
file_path = os.path.join(data_root, data_set + '.tar.bz2')
|
||||
logging.info(f'Getting {data_set}')
|
||||
__maybe_download_file(file_path, URL)
|
||||
logging.info(f'Extracting {data_set}')
|
||||
__extract_all_files(file_path, data_folder)
|
||||
|
||||
logging.info(f'Processing {data_set}')
|
||||
__process_data(
|
||||
data_folder,
|
||||
data_folder,
|
||||
num_processes=args.num_processes,
|
||||
rebalance=args.rebalance,
|
||||
class_split=args.class_split,
|
||||
skip_duration=args.skip_duration,
|
||||
)
|
||||
logging.info('Done!')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,503 @@
|
||||
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""
|
||||
Usage:
|
||||
|
||||
python process_vad_data.py \
|
||||
--out_dir=<output path to where the generated manifest should be stored> \
|
||||
--speech_data_root=<path where the speech data are stored> \
|
||||
--background_data_root=<path where the background data are stored> \
|
||||
--rebalance_method=<'under' or 'over' or 'fixed'> \
|
||||
--log
|
||||
(Optional --demo (for demonstration in tutorial). If you want to use your own background noise data, make sure to delete --demo)
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tarfile
|
||||
import urllib.request
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
from nemo.utils.tar_utils import safe_extract
|
||||
|
||||
sr = 16000
|
||||
|
||||
# google speech command v2
|
||||
URL = "http://download.tensorflow.org/data/speech_commands_v0.02.tar.gz"
|
||||
|
||||
|
||||
def __maybe_download_file(destination: str, source: str):
|
||||
"""
|
||||
Downloads source to destination if it doesn't exist.
|
||||
If exists, skips download
|
||||
Args:
|
||||
destination: local filepath
|
||||
source: url of resource
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
if not os.path.exists(destination):
|
||||
logging.info(f"{destination} does not exist. Downloading ...")
|
||||
urllib.request.urlretrieve(source, filename=destination + '.tmp')
|
||||
os.rename(destination + '.tmp', destination)
|
||||
logging.info(f"Downloaded {destination}.")
|
||||
else:
|
||||
logging.info(f"Destination {destination} exists. Skipping.")
|
||||
return destination
|
||||
|
||||
|
||||
def extract_file(filepath: str, data_dir: str):
|
||||
try:
|
||||
with tarfile.open(filepath) as tar:
|
||||
safe_extract(tar, data_dir)
|
||||
except Exception:
|
||||
logging.info('Not extracting. Maybe already there?')
|
||||
|
||||
|
||||
def __extract_all_files(filepath: str, data_root: str, data_dir: str):
|
||||
if not os.path.exists(data_dir):
|
||||
extract_file(filepath, data_dir)
|
||||
else:
|
||||
logging.info(f'Skipping extracting. Data already there {data_dir}')
|
||||
|
||||
|
||||
def split_train_val_test(data_dir, file_type, test_size=0.1, val_size=0.1, demo=False):
|
||||
X = []
|
||||
if file_type == "speech":
|
||||
for o in os.listdir(data_dir):
|
||||
if os.path.isdir(os.path.join(data_dir, o)) and o.split("/")[-1] != "_background_noise_":
|
||||
X.extend(glob.glob(os.path.join(data_dir, o) + '/*.wav'))
|
||||
|
||||
if demo:
|
||||
logging.info(
|
||||
f"For Demonstration, we use {int(len(X)/100)}/{len(X)} speech data. Make sure to remove --demo flag when you actually train your model!"
|
||||
)
|
||||
X = np.random.choice(X, int(len(X) / 100), replace=False)
|
||||
|
||||
else:
|
||||
for o in os.listdir(data_dir):
|
||||
if os.path.isdir(os.path.join(data_dir, o)):
|
||||
X.extend(glob.glob(os.path.join(data_dir, o) + '/*.wav'))
|
||||
else: # for using "_background_noise_" from google speech commands as background data
|
||||
if o.endswith(".wav"):
|
||||
X.append(os.path.join(data_dir, o))
|
||||
|
||||
X_train, X_test = train_test_split(X, test_size=test_size, random_state=1)
|
||||
val_size_tmp = val_size / (1 - test_size)
|
||||
X_train, X_val = train_test_split(X_train, test_size=val_size_tmp, random_state=1)
|
||||
|
||||
with open(os.path.join(data_dir, file_type + "_training_list.txt"), "w") as outfile:
|
||||
outfile.write("\n".join(X_train))
|
||||
with open(os.path.join(data_dir, file_type + "_testing_list.txt"), "w") as outfile:
|
||||
outfile.write("\n".join(X_test))
|
||||
with open(os.path.join(data_dir, file_type + "_validation_list.txt"), "w") as outfile:
|
||||
outfile.write("\n".join(X_val))
|
||||
|
||||
logging.info(f'Overall: {len(X)}, Train: {len(X_train)}, Validatoin: {len(X_val)}, Test: {len(X_test)}')
|
||||
logging.info(f"Finish spliting train, val and test for {file_type}. Write to files!")
|
||||
|
||||
|
||||
def process_google_speech_train(data_dir):
|
||||
X = []
|
||||
for o in os.listdir(data_dir):
|
||||
if os.path.isdir(os.path.join(data_dir, o)) and o.split("/")[-1] != "_background_noise_":
|
||||
X.extend(glob.glob(os.path.join(data_dir, o) + '/*.wav'))
|
||||
|
||||
short_files = [i.split(data_dir)[1] for i in files]
|
||||
|
||||
with open(os.path.join(data_dir, 'testing_list.txt'), 'r') as allfile:
|
||||
testing_list = allfile.read().splitlines()
|
||||
|
||||
with open(os.path.join(data_dir, 'validation_list.txt'), 'r') as allfile:
|
||||
validation_list = allfile.read().splitlines()
|
||||
|
||||
exist_set = set(testing_list).copy()
|
||||
exist_set.update(set(validation_list))
|
||||
|
||||
training_list = [i for i in short_files if i not in exist_set]
|
||||
|
||||
with open(os.path.join(data_dir, "training_list.txt"), "w") as outfile:
|
||||
outfile.write("\n".join(training_list))
|
||||
|
||||
logging.info(
|
||||
f'Overall: {len(files)}, Train: {len(training_list)}, Validatoin: {len(validation_list)}, Test: {len(testing_list)}'
|
||||
)
|
||||
|
||||
|
||||
def write_manifest(
|
||||
out_dir,
|
||||
files,
|
||||
prefix,
|
||||
manifest_name,
|
||||
start=0.0,
|
||||
end=None,
|
||||
duration_stride=1.0,
|
||||
duration_max=None,
|
||||
duration_limit=100.0,
|
||||
filter_long=False,
|
||||
):
|
||||
"""
|
||||
Given a list of files, segment each file and write them to manifest with restrictions.
|
||||
Args:
|
||||
out_dir: directory of generated manifest
|
||||
files: list of files to be processed
|
||||
prefix: label of samples
|
||||
manifest_name: name of generated manifest
|
||||
start: beginning of audio of generating segment
|
||||
end: end of audio of generating segment
|
||||
duration_stride: stride for segmenting audio samples
|
||||
duration_max: duration for each segment
|
||||
duration_limit: duration threshold for filtering out long audio samples
|
||||
filter_long: boolean to determine whether to filter out long audio samples
|
||||
Returns:
|
||||
"""
|
||||
seg_num = 0
|
||||
skip_num = 0
|
||||
if duration_max is None:
|
||||
duration_max = 1e9
|
||||
|
||||
if not os.path.exists(out_dir):
|
||||
logging.info(f'Outdir {out_dir} does not exist. Creat directory.')
|
||||
os.mkdir(out_dir)
|
||||
|
||||
output_path = os.path.join(out_dir, manifest_name + '.json')
|
||||
with open(output_path, 'w') as fout:
|
||||
for file in files:
|
||||
label = prefix
|
||||
|
||||
try:
|
||||
x, _sr = librosa.load(file, sr=sr)
|
||||
duration = librosa.get_duration(y=x, sr=sr)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if filter_long and duration > duration_limit:
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
offsets = []
|
||||
durations = []
|
||||
|
||||
if duration > duration_max:
|
||||
current_offset = start
|
||||
|
||||
while current_offset < duration:
|
||||
if end is not None and current_offset > end:
|
||||
break
|
||||
|
||||
difference = duration - current_offset
|
||||
|
||||
if difference < duration_max:
|
||||
break
|
||||
|
||||
offsets.append(current_offset)
|
||||
durations.append(duration_max)
|
||||
|
||||
current_offset += duration_stride
|
||||
|
||||
else:
|
||||
# Duration is not long enough! Skip
|
||||
skip_num += 1
|
||||
|
||||
for duration, offset in zip(durations, offsets):
|
||||
metadata = {
|
||||
'audio_filepath': file,
|
||||
'duration': duration,
|
||||
'label': label,
|
||||
'text': '_', # for compatibility with ASRAudioText
|
||||
'offset': offset,
|
||||
}
|
||||
json.dump(metadata, fout)
|
||||
fout.write('\n')
|
||||
fout.flush()
|
||||
seg_num += 1
|
||||
return skip_num, seg_num, output_path
|
||||
|
||||
|
||||
def load_list_write_manifest(
|
||||
data_dir,
|
||||
out_dir,
|
||||
filename,
|
||||
prefix,
|
||||
start,
|
||||
end,
|
||||
duration_stride=1.0,
|
||||
duration_max=1.0,
|
||||
duration_limit=100.0,
|
||||
filter_long=True,
|
||||
):
|
||||
|
||||
filename = prefix + '_' + filename
|
||||
file_path = os.path.join(data_dir, filename)
|
||||
|
||||
with open(file_path, 'r') as allfile:
|
||||
files = allfile.read().splitlines()
|
||||
|
||||
manifest_name = filename.split('_list.txt')[0] + '_manifest'
|
||||
skip_num, seg_num, output_path = write_manifest(
|
||||
out_dir,
|
||||
files,
|
||||
prefix,
|
||||
manifest_name,
|
||||
start,
|
||||
end,
|
||||
duration_stride,
|
||||
duration_max,
|
||||
duration_limit,
|
||||
filter_long=True,
|
||||
)
|
||||
return skip_num, seg_num, output_path
|
||||
|
||||
|
||||
def rebalance_json(data_dir, data_json, num, prefix):
|
||||
data = []
|
||||
seg = 0
|
||||
with open(data_json, 'r') as f:
|
||||
for line in f:
|
||||
data.append(json.loads(line))
|
||||
|
||||
filename = data_json.split('/')[-1]
|
||||
fout_path = os.path.join(data_dir, prefix + "_" + filename)
|
||||
|
||||
if len(data) >= num:
|
||||
selected_sample = np.random.choice(data, num, replace=False)
|
||||
else:
|
||||
selected_sample = np.random.choice(data, num, replace=True)
|
||||
|
||||
with open(fout_path, 'a') as fout:
|
||||
for i in selected_sample:
|
||||
seg += 1
|
||||
json.dump(i, fout)
|
||||
fout.write('\n')
|
||||
fout.flush()
|
||||
|
||||
logging.info(f'Get {seg}/{num} to {fout_path} from {data_json}')
|
||||
return fout_path
|
||||
|
||||
|
||||
def generate_variety_noise(data_dir, filename, prefix):
|
||||
|
||||
curr_dir = data_dir.split("_background_noise_")[0]
|
||||
silence_path = os.path.join(curr_dir, "_background_noise_more")
|
||||
|
||||
if not os.path.exists(silence_path):
|
||||
os.mkdir(silence_path)
|
||||
|
||||
silence_stride = 1000 # stride = 1/16 seconds
|
||||
sampling_rate = 16000
|
||||
|
||||
silence_files = []
|
||||
rng = np.random.RandomState(0)
|
||||
|
||||
filename = prefix + '_' + filename
|
||||
file_path = os.path.join(data_dir, filename)
|
||||
|
||||
with open(file_path, 'r') as allfile:
|
||||
files = allfile.read().splitlines()
|
||||
|
||||
for file in files:
|
||||
y, sr = librosa.load(path=file, sr=sampling_rate)
|
||||
|
||||
for i in range(
|
||||
0, len(y) - sampling_rate, silence_stride * 100
|
||||
): # stride * 100 to generate less samples for demo
|
||||
file_name = "{}_{}.wav".format(file.split("/")[-1], i)
|
||||
y_slice = y[i : i + sampling_rate]
|
||||
magnitude = rng.uniform(0.0, 1.0)
|
||||
y_slice *= magnitude
|
||||
out_file_path = os.path.join(silence_path, file_name)
|
||||
sf.write(out_file_path, y_slice, sr)
|
||||
|
||||
silence_files.append(out_file_path)
|
||||
|
||||
new_list_file = os.path.join(silence_path, filename)
|
||||
with open(new_list_file, "w") as outfile:
|
||||
outfile.write("\n".join(silence_files))
|
||||
|
||||
logging.info(f"Generate {len(out_file_path)} background files for {file_path}. => {new_list_file} !")
|
||||
return len(silence_files)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Speech and backgound data download and preprocess')
|
||||
parser.add_argument("--out_dir", required=False, default='./manifest/', type=str)
|
||||
parser.add_argument("--speech_data_root", required=True, default=None, type=str)
|
||||
parser.add_argument("--background_data_root", required=True, default=None, type=str)
|
||||
parser.add_argument('--test_size', required=False, default=0.1, type=float)
|
||||
parser.add_argument('--val_size', required=False, default=0.1, type=float)
|
||||
parser.add_argument('--window_length_in_sec', required=False, default=0.63, type=float)
|
||||
parser.add_argument('--log', required=False, action='store_true')
|
||||
parser.add_argument('--rebalance_method', required=False, default=None, type=str)
|
||||
parser.add_argument('--demo', required=False, action='store_true')
|
||||
parser.set_defaults(log=False, generate=False)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.rebalance_method:
|
||||
rebalance = False
|
||||
else:
|
||||
if args.rebalance_method != 'over' and args.rebalance_method != 'under' and args.rebalance_method != 'fixed':
|
||||
raise NameError("Please select a valid sampling method: over/under/fixed.")
|
||||
else:
|
||||
rebalance = True
|
||||
|
||||
if args.log:
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# Download speech data
|
||||
speech_data_root = args.speech_data_root
|
||||
data_set = "google_speech_recognition_v2"
|
||||
speech_data_folder = os.path.join(speech_data_root, data_set)
|
||||
|
||||
background_data_folder = args.background_data_root
|
||||
logging.info(f"Working on: {data_set}")
|
||||
|
||||
# Download and extract speech data
|
||||
if not os.path.exists(speech_data_folder):
|
||||
file_path = os.path.join(speech_data_root, data_set + ".tar.bz2")
|
||||
logging.info(f"Getting {data_set}")
|
||||
__maybe_download_file(file_path, URL)
|
||||
logging.info(f"Extracting {data_set}")
|
||||
__extract_all_files(file_path, speech_data_root, speech_data_folder)
|
||||
|
||||
logging.info(f"Split speech data!")
|
||||
# dataset provide testing.txt and validation.txt feel free to split data using that with process_google_speech_train
|
||||
split_train_val_test(speech_data_folder, "speech", args.test_size, args.val_size, args.demo)
|
||||
|
||||
logging.info(f"Split background data!")
|
||||
split_train_val_test(background_data_folder, "background", args.test_size, args.val_size)
|
||||
|
||||
out_dir = args.out_dir
|
||||
|
||||
# Process Speech manifest
|
||||
logging.info(f"=== Write speech data to manifest!")
|
||||
skip_num_val, speech_seg_num_val, speech_val = load_list_write_manifest(
|
||||
speech_data_folder,
|
||||
out_dir,
|
||||
'validation_list.txt',
|
||||
'speech',
|
||||
0.2,
|
||||
0.8,
|
||||
args.window_length_in_sec,
|
||||
args.window_length_in_sec,
|
||||
)
|
||||
skip_num_test, speech_seg_num_test, speech_test = load_list_write_manifest(
|
||||
speech_data_folder, out_dir, 'testing_list.txt', 'speech', 0.2, 0.8, 0.01, args.window_length_in_sec
|
||||
)
|
||||
skip_num_train, speech_seg_num_train, speech_train = load_list_write_manifest(
|
||||
speech_data_folder,
|
||||
out_dir,
|
||||
'training_list.txt',
|
||||
'speech',
|
||||
0.2,
|
||||
0.8,
|
||||
args.window_length_in_sec,
|
||||
args.window_length_in_sec,
|
||||
)
|
||||
|
||||
logging.info(f'Val: Skip {skip_num_val} samples. Get {speech_seg_num_val} segments! => {speech_val} ')
|
||||
logging.info(f'Test: Skip {skip_num_test} samples. Get {speech_seg_num_test} segments! => {speech_test}')
|
||||
logging.info(f'Train: Skip {skip_num_train} samples. Get {speech_seg_num_train} segments!=> {speech_train}')
|
||||
|
||||
# Process background manifest
|
||||
# if we select to generate more background noise data
|
||||
if args.demo:
|
||||
logging.info("Start generating more background noise data")
|
||||
generate_variety_noise(background_data_folder, 'validation_list.txt', 'background')
|
||||
generate_variety_noise(background_data_folder, 'training_list.txt', 'background')
|
||||
generate_variety_noise(background_data_folder, 'testing_list.txt', 'background')
|
||||
background_data_folder = os.path.join(
|
||||
background_data_folder.split("_background_noise_")[0], "_background_noise_more"
|
||||
)
|
||||
|
||||
logging.info(f"=== Write background data to manifest!")
|
||||
skip_num_val, background_seg_num_val, background_val = load_list_write_manifest(
|
||||
background_data_folder, out_dir, 'validation_list.txt', 'background', 0, None, 0.15, args.window_length_in_sec
|
||||
)
|
||||
skip_num_test, background_seg_num_test, background_test = load_list_write_manifest(
|
||||
background_data_folder, out_dir, 'testing_list.txt', 'background', 0, None, 0.01, args.window_length_in_sec
|
||||
)
|
||||
skip_num_train, background_seg_num_train, background_train = load_list_write_manifest(
|
||||
background_data_folder, out_dir, 'training_list.txt', 'background', 0, None, 0.15, args.window_length_in_sec
|
||||
)
|
||||
|
||||
logging.info(f'Val: Skip {skip_num_val} samples. Get {background_seg_num_val} segments! => {background_val}')
|
||||
logging.info(f'Test: Skip {skip_num_test} samples. Get {background_seg_num_test} segments! => {background_test}')
|
||||
logging.info(
|
||||
f'Train: Skip {skip_num_train} samples. Get {background_seg_num_train} segments! => {background_train}'
|
||||
)
|
||||
min_val, max_val = min(speech_seg_num_val, background_seg_num_val), max(speech_seg_num_val, background_seg_num_val)
|
||||
min_test, max_test = (
|
||||
min(speech_seg_num_test, background_seg_num_test),
|
||||
max(speech_seg_num_test, background_seg_num_test),
|
||||
)
|
||||
min_train, max_train = (
|
||||
min(speech_seg_num_train, background_seg_num_train),
|
||||
max(speech_seg_num_train, background_seg_num_train),
|
||||
)
|
||||
|
||||
logging.info('Finish generating manifest!')
|
||||
|
||||
if rebalance:
|
||||
# Random Oversampling: Randomly duplicate examples in the minority class.
|
||||
# Random Undersampling: Randomly delete examples in the majority class.
|
||||
if args.rebalance_method == 'under':
|
||||
logging.info(f"Rebalancing number of samples in classes using {args.rebalance_method} sampling.")
|
||||
logging.info(f'Val: {min_val} Test: {min_test} Train: {min_train}!')
|
||||
|
||||
rebalance_json(out_dir, background_val, min_val, 'balanced')
|
||||
rebalance_json(out_dir, background_test, min_test, 'balanced')
|
||||
rebalance_json(out_dir, background_train, min_train, 'balanced')
|
||||
|
||||
rebalance_json(out_dir, speech_val, min_val, 'balanced')
|
||||
rebalance_json(out_dir, speech_test, min_test, 'balanced')
|
||||
rebalance_json(out_dir, speech_train, min_train, 'balanced')
|
||||
|
||||
if args.rebalance_method == 'over':
|
||||
logging.info(f"Rebalancing number of samples in classes using {args.rebalance_method} sampling.")
|
||||
logging.info(f'Val: {max_val} Test: {max_test} Train: {max_train}!')
|
||||
|
||||
rebalance_json(out_dir, background_val, max_val, 'balanced')
|
||||
rebalance_json(out_dir, background_test, max_test, 'balanced')
|
||||
rebalance_json(out_dir, background_train, max_train, 'balanced')
|
||||
|
||||
rebalance_json(out_dir, speech_val, max_val, 'balanced')
|
||||
rebalance_json(out_dir, speech_test, max_test, 'balanced')
|
||||
rebalance_json(out_dir, speech_train, max_train, 'balanced')
|
||||
|
||||
if args.rebalance_method == 'fixed':
|
||||
fixed_test, fixed_val, fixed_train = 200, 100, 500
|
||||
logging.info(f"Rebalancing number of samples in classes using {args.rebalance_method} sampling.")
|
||||
logging.info(f'Val: {fixed_val} Test: {fixed_test} Train: {fixed_train}!')
|
||||
|
||||
rebalance_json(out_dir, background_val, fixed_val, 'balanced')
|
||||
rebalance_json(out_dir, background_test, fixed_test, 'balanced')
|
||||
rebalance_json(out_dir, background_train, fixed_train, 'balanced')
|
||||
|
||||
rebalance_json(out_dir, speech_val, fixed_val, 'balanced')
|
||||
rebalance_json(out_dir, speech_test, fixed_test, 'balanced')
|
||||
rebalance_json(out_dir, speech_train, fixed_train, 'balanced')
|
||||
else:
|
||||
logging.info("Don't rebalance number of samples in classes.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
# Speaker Tasks Dataset Scripts
|
||||
|
||||
In this folder are scripts to download speaker tasks (mainly for diarization) datasets. These scripts will return NeMo format manifest files to use with Diarization.
|
||||
|
||||
We also have scripts for CallHome and DIHARD3, however the data has to be downloaded separately. If you require the scripts please leave an issue.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# downloads the training/eval set for AISHELL Diarization.
|
||||
# the training dataset is around 170GiB, to skip pass the --skip_train flag.
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import tarfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import create_manifest
|
||||
from nemo.utils.tar_utils import safe_extract
|
||||
|
||||
train_url = "https://www.openslr.org/resources/111/train_{}.tar.gz"
|
||||
train_datasets = ["S", "M", "L"]
|
||||
|
||||
eval_url = "https://www.openslr.org/resources/111/test.tar.gz"
|
||||
|
||||
|
||||
def _load_sox_transformer():
|
||||
try:
|
||||
from sox import Transformer
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Optional dependency 'sox' is required by this script. Install it with: pip install sox"
|
||||
) from None
|
||||
|
||||
return Transformer
|
||||
|
||||
|
||||
def extract_file(filepath: str, data_dir: str):
|
||||
try:
|
||||
with tarfile.open(filepath) as tar:
|
||||
safe_extract(tar, data_dir)
|
||||
except Exception:
|
||||
logging.info("Not extracting. Maybe already there?")
|
||||
|
||||
|
||||
def __process_data(dataset_url: str, dataset_path: Path, manifest_output_path: Path):
|
||||
os.makedirs(dataset_path, exist_ok=True)
|
||||
tar_file_path = os.path.join(dataset_path, os.path.basename(dataset_url))
|
||||
if not os.path.exists(tar_file_path):
|
||||
urllib.request.urlretrieve(dataset_url, filename=tar_file_path)
|
||||
extract_file(tar_file_path, str(dataset_path))
|
||||
wav_path = dataset_path / 'converted_wav/'
|
||||
extracted_dir = Path(tar_file_path).stem.replace('.tar', '')
|
||||
flac_path = dataset_path / (extracted_dir + '/wav/')
|
||||
__process_flac_audio(flac_path, wav_path)
|
||||
|
||||
audio_files = [os.path.join(os.path.abspath(wav_path), file) for file in os.listdir(str(wav_path))]
|
||||
rttm_files = glob.glob(str(dataset_path / (extracted_dir + '/TextGrid/*.rttm')))
|
||||
rttm_files = [os.path.abspath(file) for file in rttm_files]
|
||||
|
||||
audio_list = dataset_path / 'audio_files.txt'
|
||||
rttm_list = dataset_path / 'rttm_files.txt'
|
||||
with open(audio_list, 'w') as f:
|
||||
f.write('\n'.join(audio_files))
|
||||
with open(rttm_list, 'w') as f:
|
||||
f.write('\n'.join(rttm_files))
|
||||
create_manifest(
|
||||
str(audio_list),
|
||||
manifest_output_path,
|
||||
rttm_path=str(rttm_list),
|
||||
)
|
||||
|
||||
|
||||
def __process_flac_audio(flac_path, wav_path):
|
||||
Transformer = _load_sox_transformer()
|
||||
os.makedirs(wav_path, exist_ok=True)
|
||||
flac_files = os.listdir(flac_path)
|
||||
for flac_file in flac_files:
|
||||
# Convert FLAC file to WAV
|
||||
id = Path(flac_file).stem
|
||||
wav_file = os.path.join(wav_path, id + ".wav")
|
||||
if not os.path.exists(wav_file):
|
||||
Transformer().build(os.path.join(flac_path, flac_file), wav_file)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Aishell Data download")
|
||||
parser.add_argument("--data_root", default='./', type=str)
|
||||
parser.add_argument("--output_manifest_path", default='aishell_diar_manifest.json', type=str)
|
||||
parser.add_argument("--skip_train", help="skip downloading the training dataset", action="store_true")
|
||||
args = parser.parse_args()
|
||||
data_root = Path(args.data_root)
|
||||
data_root.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
if not args.skip_train:
|
||||
for tag in train_datasets:
|
||||
dataset_url = train_url.format(tag)
|
||||
dataset_path = data_root / f'{tag}/'
|
||||
manifest_output_path = data_root / f'train_{tag}_manifest.json'
|
||||
__process_data(
|
||||
dataset_url=dataset_url, dataset_path=dataset_path, manifest_output_path=manifest_output_path
|
||||
)
|
||||
# create test dataset
|
||||
dataset_path = data_root / f'eval/'
|
||||
manifest_output_path = data_root / f'eval_manifest.json'
|
||||
__process_data(dataset_url=eval_url, dataset_path=dataset_path, manifest_output_path=manifest_output_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,102 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# Download the AMI test dataset used to evaluate Speaker Diarization
|
||||
# More information here: https://groups.inf.ed.ac.uk/ami/corpus/
|
||||
# USAGE: python get_ami_data.py
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import create_manifest
|
||||
|
||||
rttm_url = "https://raw.githubusercontent.com/BUTSpeechFIT/AMI-diarization-setup/main/only_words/rttms/{}/{}.rttm"
|
||||
uem_url = "https://raw.githubusercontent.com/BUTSpeechFIT/AMI-diarization-setup/main/uems/{}/{}.uem"
|
||||
list_url = "https://raw.githubusercontent.com/BUTSpeechFIT/AMI-diarization-setup/main/lists/{}.meetings.txt"
|
||||
|
||||
|
||||
audio_types = ['Mix-Headset', 'Array1-01']
|
||||
|
||||
# these two IDs in the train set are missing download links for Array1-01.
|
||||
# We exclude them as a result.
|
||||
not_found_ids = ['IS1007d', 'IS1003b']
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Download the AMI Corpus Dataset for Speaker Diarization")
|
||||
parser.add_argument(
|
||||
"--test_manifest_filepath",
|
||||
help="path to output test manifest file",
|
||||
type=str,
|
||||
default='AMI_test_manifest.json',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dev_manifest_filepath",
|
||||
help="path to output dev manifest file",
|
||||
type=str,
|
||||
default='AMI_dev_manifest.json',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--train_manifest_filepath",
|
||||
help="path to output train manifest file",
|
||||
type=str,
|
||||
default='AMI_train_manifest.json',
|
||||
)
|
||||
parser.add_argument("--data_root", help="path to output data directory", type=str, default="ami_dataset")
|
||||
args = parser.parse_args()
|
||||
|
||||
data_path = os.path.abspath(args.data_root)
|
||||
os.makedirs(data_path, exist_ok=True)
|
||||
|
||||
for manifest_path, split in (
|
||||
(args.test_manifest_filepath, 'test'),
|
||||
(args.dev_manifest_filepath, 'dev'),
|
||||
(args.train_manifest_filepath, 'train'),
|
||||
):
|
||||
split_path = os.path.join(data_path, split)
|
||||
audio_path = os.path.join(split_path, "audio")
|
||||
os.makedirs(split_path, exist_ok=True)
|
||||
rttm_path = os.path.join(split_path, "rttm")
|
||||
uem_path = os.path.join(split_path, "uem")
|
||||
|
||||
subprocess.run(["wget", "-P", split_path, list_url.format(split)])
|
||||
with open(os.path.join(split_path, f"{split}.meetings.txt")) as f:
|
||||
ids = f.read().strip().split('\n')
|
||||
for id in [file_id for file_id in ids if file_id not in not_found_ids]:
|
||||
for audio_type in audio_types:
|
||||
audio_type_path = os.path.join(audio_path, audio_type)
|
||||
os.makedirs(audio_type_path, exist_ok=True)
|
||||
audio_download = (
|
||||
f"https://groups.inf.ed.ac.uk/ami/AMICorpusMirror//amicorpus/{id}/audio/" f"{id}.{audio_type}.wav"
|
||||
)
|
||||
subprocess.run(["wget", "-P", audio_type_path, audio_download])
|
||||
rttm_download = rttm_url.format(split, id)
|
||||
subprocess.run(["wget", "-P", rttm_path, rttm_download])
|
||||
uem_download = uem_url.format(split, id)
|
||||
subprocess.run(["wget", "-P", uem_path, uem_download])
|
||||
|
||||
rttm_files_path = os.path.join(split_path, 'rttm_files.txt')
|
||||
with open(rttm_files_path, 'w') as f:
|
||||
f.write('\n'.join(os.path.join(rttm_path, p) for p in os.listdir(rttm_path)))
|
||||
uem_files_path = os.path.join(split_path, 'uem_files.txt')
|
||||
with open(uem_files_path, 'w') as f:
|
||||
f.write('\n'.join(os.path.join(uem_path, p) for p in os.listdir(uem_path)))
|
||||
for audio_type in audio_types:
|
||||
audio_type_path = os.path.join(audio_path, audio_type)
|
||||
audio_files_path = os.path.join(split_path, f'audio_files_{audio_type}.txt')
|
||||
with open(audio_files_path, 'w') as f:
|
||||
f.write('\n'.join(os.path.join(audio_type_path, p) for p in os.listdir(audio_type_path)))
|
||||
audio_type_manifest_path = manifest_path.replace('.json', f'.{audio_type}.json')
|
||||
create_manifest(
|
||||
audio_files_path, audio_type_manifest_path, rttm_path=rttm_files_path, uem_path=uem_files_path
|
||||
)
|
||||
@@ -0,0 +1,208 @@
|
||||
# Copyright (c) 2021, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# USAGE: python get_hi-mia_data.py --data_root=<where to put data>
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging as _logging
|
||||
import os
|
||||
import tarfile
|
||||
import urllib.request
|
||||
from glob import glob
|
||||
|
||||
import librosa as l
|
||||
from sklearn.model_selection import StratifiedShuffleSplit
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.utils.tar_utils import safe_extract
|
||||
|
||||
parser = argparse.ArgumentParser(description="HI-MIA Data download")
|
||||
parser.add_argument("--data_root", required=True, default=None, type=str)
|
||||
parser.add_argument("--log_level", default=20, type=int)
|
||||
args = parser.parse_args()
|
||||
logging = _logging.getLogger(__name__)
|
||||
logging.addHandler(_logging.StreamHandler())
|
||||
logging.setLevel(args.log_level)
|
||||
|
||||
URL = {
|
||||
"dev": "http://www.openslr.org/resources/85/dev.tar.gz",
|
||||
"test": "http://www.openslr.org/resources/85/test.tar.gz",
|
||||
"train": "http://www.openslr.org/resources/85/train.tar.gz",
|
||||
}
|
||||
|
||||
|
||||
def __retrieve_with_progress(source: str, filename: str):
|
||||
"""
|
||||
Downloads source to destination
|
||||
Displays progress bar
|
||||
Args:
|
||||
source: url of resource
|
||||
destination: local filepath
|
||||
Returns:
|
||||
"""
|
||||
with open(filename, "wb") as f:
|
||||
response = urllib.request.urlopen(source)
|
||||
total = response.length
|
||||
|
||||
if total is None:
|
||||
f.write(response.content)
|
||||
else:
|
||||
with tqdm(total=total, unit="B", unit_scale=True, unit_divisor=1024) as pbar:
|
||||
for data in response:
|
||||
f.write(data)
|
||||
pbar.update(len(data))
|
||||
|
||||
|
||||
def __maybe_download_file(destination: str, source: str):
|
||||
"""
|
||||
Downloads source to destination if it doesn't exist.
|
||||
If exists, skips download
|
||||
Args:
|
||||
destination: local filepath
|
||||
source: url of resource
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
source = URL[source]
|
||||
if not os.path.exists(destination) and not os.path.exists(os.path.splitext(destination)[0]):
|
||||
logging.info("{0} does not exist. Downloading ...".format(destination))
|
||||
__retrieve_with_progress(source, filename=destination + ".tmp")
|
||||
os.rename(destination + ".tmp", destination)
|
||||
logging.info("Downloaded {0}.".format(destination))
|
||||
elif os.path.exists(destination):
|
||||
logging.info("Destination {0} exists. Skipping.".format(destination))
|
||||
elif os.path.exists(os.path.splitext(destination)[0]):
|
||||
logging.warning(
|
||||
"Assuming extracted folder %s contains the extracted files from %s. Will not download.",
|
||||
os.path.basename(destination),
|
||||
destination,
|
||||
)
|
||||
return destination
|
||||
|
||||
|
||||
def __extract_all_files(filepath: str, data_root: str, data_dir: str):
|
||||
if not os.path.exists(data_dir):
|
||||
extract_file(filepath, data_root)
|
||||
audio_dir = os.path.join(data_dir, "wav")
|
||||
for subfolder, _, filelist in os.walk(audio_dir):
|
||||
for ftar in filelist:
|
||||
extract_file(os.path.join(subfolder, ftar), subfolder)
|
||||
else:
|
||||
logging.info("Skipping extracting. Data already there %s" % data_dir)
|
||||
|
||||
|
||||
def extract_file(filepath: str, data_dir: str):
|
||||
try:
|
||||
with tarfile.open(filepath, encoding='utf-8') as tar:
|
||||
safe_extract(tar, data_dir)
|
||||
except Exception:
|
||||
logging.info("Not extracting. Maybe already there?")
|
||||
|
||||
|
||||
def __remove_tarred_files(filepath: str, data_dir: str):
|
||||
if os.path.exists(data_dir) and os.path.isfile(filepath):
|
||||
logging.info("Deleting %s" % filepath)
|
||||
os.remove(filepath)
|
||||
|
||||
|
||||
def write_file(name, lines, idx):
|
||||
with open(name, "w") as fout:
|
||||
for i in idx:
|
||||
dic = lines[i]
|
||||
json.dump(dic, fout)
|
||||
fout.write("\n")
|
||||
logging.info("wrote %s", name)
|
||||
|
||||
|
||||
def __process_data(data_folder: str, data_set: str):
|
||||
"""
|
||||
To generate manifest
|
||||
Args:
|
||||
data_folder: source with wav files
|
||||
Returns:
|
||||
|
||||
"""
|
||||
fullpath = os.path.abspath(data_folder)
|
||||
filelist = glob(fullpath + "/**/*.wav", recursive=True)
|
||||
out = os.path.join(fullpath, data_set + "_all.json")
|
||||
utt2spk = os.path.join(fullpath, "utt2spk")
|
||||
utt2spk_file = open(utt2spk, "w")
|
||||
id = -2 # speaker id
|
||||
|
||||
if os.path.exists(out):
|
||||
logging.warning(
|
||||
"%s already exists and is assumed to be processed. If not, please delete %s and rerun this script",
|
||||
out,
|
||||
out,
|
||||
)
|
||||
return
|
||||
|
||||
speakers = []
|
||||
lines = []
|
||||
with open(out, "w") as outfile:
|
||||
for line in tqdm(filelist):
|
||||
line = line.strip()
|
||||
y, sr = l.load(line, sr=None)
|
||||
if sr != 16000:
|
||||
y, sr = l.load(line, sr=16000)
|
||||
l.output.write_wav(line, y, sr)
|
||||
dur = l.get_duration(y=y, sr=sr)
|
||||
if data_set == "test":
|
||||
speaker = line.split("/")[-1].split(".")[0].split("_")[0]
|
||||
else:
|
||||
speaker = line.split("/")[id]
|
||||
speaker = list(speaker)
|
||||
speaker = "".join(speaker)
|
||||
speakers.append(speaker)
|
||||
meta = {"audio_filepath": line, "duration": float(dur), "label": speaker}
|
||||
lines.append(meta)
|
||||
json.dump(meta, outfile)
|
||||
outfile.write("\n")
|
||||
utt2spk_file.write(line.split("/")[-1] + "\t" + speaker + "\n")
|
||||
|
||||
utt2spk_file.close()
|
||||
|
||||
if data_set != "test":
|
||||
sss = StratifiedShuffleSplit(n_splits=1, test_size=0.1, random_state=42)
|
||||
for train_idx, test_idx in sss.split(speakers, speakers):
|
||||
print(len(train_idx))
|
||||
|
||||
out = os.path.join(fullpath, "train.json")
|
||||
write_file(out, lines, train_idx)
|
||||
out = os.path.join(fullpath, "dev.json")
|
||||
write_file(out, lines, test_idx)
|
||||
|
||||
|
||||
def main():
|
||||
data_root = args.data_root
|
||||
for data_set in URL.keys():
|
||||
|
||||
# data_set = 'data_aishell'
|
||||
logging.info("\n\nWorking on: {0}".format(data_set))
|
||||
file_path = os.path.join(data_root, data_set + ".tgz")
|
||||
logging.info("Getting {0}".format(data_set))
|
||||
__maybe_download_file(file_path, data_set)
|
||||
logging.info("Extracting {0}".format(data_set))
|
||||
data_folder = os.path.join(data_root, data_set)
|
||||
__extract_all_files(file_path, data_root, data_folder)
|
||||
__remove_tarred_files(file_path, data_folder)
|
||||
logging.info("Processing {0}".format(data_set))
|
||||
__process_data(data_folder, data_set)
|
||||
logging.info("Done!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,95 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# downloads the training/eval set for VoxConverse.
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import create_manifest
|
||||
|
||||
dev_url = "https://www.robots.ox.ac.uk/~vgg/data/voxconverse/data/voxconverse_dev_wav.zip"
|
||||
test_url = "https://www.robots.ox.ac.uk/~vgg/data/voxconverse/data/voxconverse_test_wav.zip"
|
||||
rttm_annotations_url = "https://github.com/joonson/voxconverse/archive/refs/heads/master.zip"
|
||||
|
||||
|
||||
def extract_file(filepath: Path, data_dir: Path):
|
||||
try:
|
||||
with zipfile.ZipFile(str(filepath), 'r') as zip_ref:
|
||||
zip_ref.extractall(str(data_dir))
|
||||
except Exception:
|
||||
logging.info("Not extracting. Maybe already there?")
|
||||
|
||||
|
||||
def download_file(url: str, destination: Path) -> Path:
|
||||
urllib.request.urlretrieve(url, filename=str(destination))
|
||||
return destination
|
||||
|
||||
|
||||
def _generate_manifest(data_root: Path, audio_path: Path, rttm_path: Path, manifest_output_path: Path):
|
||||
audio_list = str(data_root / 'audio_file.txt')
|
||||
rttm_list = str(data_root / 'rttm_file.txt')
|
||||
with open(audio_list, 'w') as f:
|
||||
f.write('\n'.join([str(os.path.join(rttm_path, x)) for x in os.listdir(audio_path)]))
|
||||
with open(rttm_list, 'w') as f:
|
||||
f.write('\n'.join([str(os.path.join(rttm_path, x)) for x in os.listdir(rttm_path)]))
|
||||
create_manifest(
|
||||
audio_list,
|
||||
str(manifest_output_path),
|
||||
rttm_path=rttm_list,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="VoxConverse Data download")
|
||||
parser.add_argument("--data_root", default='./', type=str)
|
||||
args = parser.parse_args()
|
||||
data_root = Path(args.data_root)
|
||||
data_root.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
test_path = data_root / os.path.basename(test_url)
|
||||
dev_path = data_root / os.path.basename(dev_url)
|
||||
rttm_path = data_root / os.path.basename(rttm_annotations_url)
|
||||
|
||||
if not os.path.exists(test_path):
|
||||
test_path = download_file(test_url, test_path)
|
||||
if not os.path.exists(dev_path):
|
||||
dev_path = download_file(dev_url, dev_path)
|
||||
if not os.path.exists(rttm_path):
|
||||
rttm_path = download_file(rttm_annotations_url, rttm_path)
|
||||
|
||||
extract_file(test_path, data_root / 'test/')
|
||||
extract_file(dev_path, data_root / 'dev/')
|
||||
extract_file(rttm_path, data_root)
|
||||
|
||||
_generate_manifest(
|
||||
data_root=data_root,
|
||||
audio_path=os.path.abspath(data_root / 'test/voxconverse_test_wav/'),
|
||||
rttm_path=os.path.abspath(data_root / 'voxconverse-master/test/'),
|
||||
manifest_output_path=data_root / 'test_manifest.json',
|
||||
)
|
||||
_generate_manifest(
|
||||
data_root=data_root,
|
||||
audio_path=os.path.abspath(data_root / 'dev/audio/'),
|
||||
rttm_path=os.path.abspath(data_root / 'voxconverse-master/dev/'),
|
||||
manifest_output_path=data_root / 'dev_manifest.json',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,204 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
"""
|
||||
This script can be used to preprocess Spoken Wikipedia corpus before running ctc-segmentation.
|
||||
The input folder consists of subfolders with following stricture
|
||||
├── <Name of Wikipedia article>
|
||||
│ ├── aligned.swc
|
||||
│ ├── audiometa.txt
|
||||
│ ├── audio.ogg
|
||||
│ ├── info.json
|
||||
│ ├── wiki.html
|
||||
│ ├── wiki.txt
|
||||
│ └── wiki.xml
|
||||
|
||||
|
||||
## The destination folder will contain look enumerated .ogg and .txt files like this:
|
||||
├── audio
|
||||
| ├── 1.ogg
|
||||
| ├── 2.ogg
|
||||
| ...
|
||||
└── text
|
||||
├── 1.txt
|
||||
├── 2.txt
|
||||
...
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--input_folder", required=True, type=str, help="Input folder in which each subfolder contains an article"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--destination_folder", required=True, type=str, help="Destination folder with audio and text subfolder"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def replace_diacritics(text):
|
||||
text = re.sub(r"[éèëēêęěė]", "e", text)
|
||||
text = re.sub(r"[ãâāáäăâàąåạả]", "a", text)
|
||||
text = re.sub(r"[úūüùưûů]", "u", text)
|
||||
text = re.sub(r"[ôōóöõòő]", "o", text)
|
||||
text = re.sub(r"[ćçč]", "c", text)
|
||||
text = re.sub(r"[ïīíîıì]", "i", text)
|
||||
text = re.sub(r"[ñńňņ]", "n", text)
|
||||
text = re.sub(r"[țť]", "t", text)
|
||||
text = re.sub(r"[łľ]", "l", text)
|
||||
text = re.sub(r"[żžź]", "z", text)
|
||||
text = re.sub(r"[ğ]", "g", text)
|
||||
text = re.sub(r"[ř]", "r", text)
|
||||
text = re.sub(r"[ý]", "y", text)
|
||||
text = re.sub(r"[æ]", "ae", text)
|
||||
text = re.sub(r"[œ]", "oe", text)
|
||||
text = re.sub(r"[șşšś]", "s", text)
|
||||
return text
|
||||
|
||||
|
||||
def get_audio(name, n):
|
||||
"""
|
||||
Copies .ogg file. If there are several .ogg files, concatenates them.
|
||||
|
||||
Args:
|
||||
name - name of folder within Spoken Wikipedia
|
||||
n - integer that will serve as output file name, e.g. if n=1, file 1.ogg will be created
|
||||
"""
|
||||
audio_path = os.path.join(args.input_folder, name, "audio.ogg")
|
||||
if not os.path.exists(audio_path):
|
||||
## Some folders have multiple .ogg files, so we need to first combine them into one file. Example:
|
||||
## |── Universe
|
||||
## │ ├── aligned.swc
|
||||
## │ ├── audio1.ogg
|
||||
## │ ├── audio2.ogg
|
||||
## │ ├── audio3.ogg
|
||||
## │ ├── audio4.ogg
|
||||
## │ ├── audiometa.txt
|
||||
## │ ├── info.json
|
||||
## │ ├── wiki.html
|
||||
## │ ├── wiki.txt
|
||||
## │ └── wiki.xml
|
||||
|
||||
multiple_ogg_files = []
|
||||
for i in range(1, 5):
|
||||
path = os.path.join(args.input_folder, name, "audio" + str(i) + ".ogg")
|
||||
if os.path.exists(path):
|
||||
multiple_ogg_files.append(path)
|
||||
else:
|
||||
break
|
||||
if len(multiple_ogg_files) == 0:
|
||||
return
|
||||
elif len(multiple_ogg_files) == 1:
|
||||
shutil.copy(multiple_ogg_files[0], audio_path)
|
||||
else:
|
||||
tmp_file_name = "ffmeg_inputs.txt"
|
||||
print("tmp_file_name=", tmp_file_name)
|
||||
with open(tmp_file_name, "w", encoding="utf-8") as tmp_file:
|
||||
for path in multiple_ogg_files:
|
||||
tmp_file.write("file '" + path + "'\n")
|
||||
ffmpeg_cmd = ["ffmpeg", "-f", "concat", "-i", tmp_file_name, "-c", "copy", audio_path]
|
||||
print("ffmpeg command:", ffmpeg_cmd)
|
||||
subprocess.run(ffmpeg_cmd, check=True)
|
||||
|
||||
output_audio_path = args.destination_folder + "/audio/" + str(n) + ".ogg"
|
||||
shutil.copy(audio_path, output_audio_path)
|
||||
|
||||
|
||||
def get_text(name, n):
|
||||
"""
|
||||
Cleans wiki.txt.
|
||||
|
||||
Args:
|
||||
name - name of folder within Spoken Wikipedia
|
||||
n - integer that will serve as output file name, e.g. if n=1, file 1.txt will be created
|
||||
"""
|
||||
|
||||
# Then we need to clean the text
|
||||
out_text = open(args.destination_folder + "/text/" + str(n) + ".txt", "w", encoding="utf-8")
|
||||
with open(args.input_folder + "/" + name + "/wiki.txt", "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
do_break = False
|
||||
line2 = line.strip()
|
||||
ref_parts = line2.split("<ref")
|
||||
for idx, s in enumerate(ref_parts):
|
||||
if idx != 0:
|
||||
s = "<ref" + s
|
||||
if s.startswith("[[Image") and s.endswith("]]"):
|
||||
continue
|
||||
if s.startswith("[[File") and s.endswith("]]"):
|
||||
continue
|
||||
if s.startswith(":"):
|
||||
continue
|
||||
if s.startswith("{|") or s.startswith("|}") or s.startswith("|") or s.startswith("!"):
|
||||
continue
|
||||
if s.startswith("{{") and (s.endswith("}}") or "}}" not in s):
|
||||
continue
|
||||
if s.startswith("{{pp-move"):
|
||||
continue
|
||||
s = re.sub(r"\[\[Image\:[^\]]+\]\]", r"", s)
|
||||
s = re.sub(r"\[\[File\:[^\]]+\]\]", r"", s)
|
||||
s = re.sub(r"\[http[^\]]+\]", r"", s)
|
||||
s = re.sub(r"<math>[^<>]+</math>", r"", s)
|
||||
s = re.sub(r"<!\-\-.+\-\->", r"", s) # <!--DashBot--> can be inside <ref>
|
||||
s = re.sub(r"<ref>.+</ref>", r"", s)
|
||||
s = re.sub(r"<ref .+</ref>", r"", s)
|
||||
s = re.sub(r"<ref[^<>]+/>", r"", s)
|
||||
s = re.sub(r"<[^ <>]+>", r"", s) # <sub>, <sup>, </u>
|
||||
if (
|
||||
re.match(r"== *Notes *==", s)
|
||||
or re.match(r"== *References *==", s)
|
||||
or re.match(r"== *External links *==", s)
|
||||
or re.match(r"== *See also *==", s)
|
||||
):
|
||||
do_break = True
|
||||
break
|
||||
s = re.sub(r"{{convert\|(\d+)\|(\w+)\|[^}]+}}", r"\g<1> \g<2>", s) # {{convert|7600|lb|kg}}
|
||||
s = re.sub(r"{{cquote\|", r"", s)
|
||||
s = re.sub(r"{{[^{}]+}}", r"", s)
|
||||
s = s.replace("{{", "").replace("}}", "")
|
||||
s = re.sub(r"(lang[^()]+)", r"", s) # (lang-bn...)
|
||||
s = re.sub(r"==+", r"", s)
|
||||
s = re.sub(r"''+", r" ", s) # remove multiple quotes
|
||||
s = re.sub(r" '", r" ", s) # remove quote at the beginning
|
||||
s = re.sub(r"' ", r" ", s) # remove quote at the end
|
||||
s = re.sub(r"[…\*]", r" ", s)
|
||||
s = re.sub(r"\\u....", r" ", s) # remove unicode
|
||||
s = re.sub(r"&[^ ;&]+;", r"", s) # —
|
||||
|
||||
s = replace_diacritics(s)
|
||||
|
||||
s = re.sub(r"\[\[[^\]]+\|([^\]]+)\]\]", r"\g<1>", s) # if several variants, take the last one
|
||||
s = re.sub(r"\[\[([^\]]+)\]\]", r"\g<1>", s)
|
||||
|
||||
out_text.write(s + "\n")
|
||||
if do_break:
|
||||
break
|
||||
out_text.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
n = 0
|
||||
for name in os.listdir(args.input_folder):
|
||||
n += 1
|
||||
if not os.path.exists(args.input_folder + "/" + name + "/wiki.txt"):
|
||||
print("wiki.txt does not exist in " + name)
|
||||
continue
|
||||
get_audio(name, n)
|
||||
get_text(name, n)
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
## Download the Spoken Wikipedia corpus for English
|
||||
## Note, that there are some other languages available
|
||||
## @InProceedings{KHN16.518,
|
||||
## author = {Arne K{\"o}hn and Florian Stegen and Timo Baumann},
|
||||
## title = {Mining the Spoken Wikipedia for Speech Data and Beyond},
|
||||
## booktitle = {Proceedings of the Tenth International Conference on Language Resources and Evaluation (LREC 2016)},
|
||||
## year = {2016},
|
||||
## month = {may},
|
||||
## date = {23-28},
|
||||
## location = {Portorož, Slovenia},
|
||||
## editor = {Nicoletta Calzolari (Conference Chair) and Khalid Choukri and Thierry Declerck and Marko Grobelnik and Bente Maegaard and Joseph Mariani and Asuncion Moreno and Jan Odijk and Stelios Piperidis},
|
||||
## publisher = {European Language Resources Association (ELRA)},
|
||||
## address = {Paris, France},
|
||||
## isbn = {978-2-9517408-9-1},
|
||||
## islrn = {684-927-624-257-3/},
|
||||
## language = {english}
|
||||
## }
|
||||
|
||||
wget https://corpora.uni-hamburg.de/hzsk/de/islandora/object/file:swc-2.0_en-with-audio/datastream/TAR/en-with-audio.tar .
|
||||
tar -xvf en-with-audio.tar
|
||||
|
||||
## We get a folder English with 1339 subfolders, each subfolder corresponds to a Wikipedia article. Example:
|
||||
## ├── Universal_suffrage
|
||||
## │ ├── aligned.swc
|
||||
## │ ├── audiometa.txt
|
||||
## │ ├── audio.ogg
|
||||
## │ ├── info.json
|
||||
## │ ├── wiki.html
|
||||
## │ ├── wiki.txt
|
||||
## │ └── wiki.xml
|
||||
|
||||
## We will use two files: audio.ogg and wiki.txt
|
||||
|
||||
## Some folders have multiple .ogg files, this will be handled during preprocess.py. Example:
|
||||
## |── Universe
|
||||
## │ ├── aligned.swc
|
||||
## │ ├── audio1.ogg
|
||||
## │ ├── audio2.ogg
|
||||
## │ ├── audio3.ogg
|
||||
## │ ├── audio4.ogg
|
||||
## │ ├── audiometa.txt
|
||||
## │ ├── info.json
|
||||
## │ ├── wiki.html
|
||||
## │ ├── wiki.txt
|
||||
## │ └── wiki.xml
|
||||
|
||||
## Some rare folders are incomplete, these will be skipped during preprocessing.
|
||||
|
||||
## Rename some folders with special symbols because they cause problems to ffmpeg when concatening multiple .ogg files
|
||||
mv "english/The_Hitchhiker%27s_Guide_to_the_Galaxy" "english/The_Hitchhikers_guide_to_the_Galaxy"
|
||||
mv "english/SummerSlam_(2003)" "english/SummerSlam_2003"
|
||||
mv "english/Over_the_Edge_(1999)" "english/Over_the_Edge_1999"
|
||||
mv "english/Lost_(TV_series)" "english/Lost_TV_series"
|
||||
mv "english/S._A._Andr%c3%a9e%27s_Arctic_Balloon_Expedition_of_1897" "english/S_A_Andres_Arctic_Balloon_Expedition_of_1897"
|
||||
|
||||
## path to NeMo repository, e.g. /home/user/NeMo
|
||||
NEMO_PATH=
|
||||
|
||||
INPUT_DIR="english"
|
||||
OUTPUT_DIR=${INPUT_DIR}_result
|
||||
|
||||
rm -rf $OUTPUT_DIR
|
||||
rm -rf ${INPUT_DIR}_prepared
|
||||
mkdir ${INPUT_DIR}_prepared
|
||||
mkdir ${INPUT_DIR}_prepared/audio
|
||||
mkdir ${INPUT_DIR}_prepared/text
|
||||
python ${NEMO_PATH}/scripts/dataset_processing/spoken_wikipedia/preprocess.py --input_folder ${INPUT_DIR} --destination_folder ${INPUT_DIR}_prepared
|
||||
|
||||
## Now we have ${INPUT_DIR}_prepared folder with the following structure:
|
||||
## ├── audio
|
||||
## | ├── 1.ogg
|
||||
## | ├── 2.ogg
|
||||
## | ...
|
||||
## └── text
|
||||
## ├── 1.txt
|
||||
## ├── 2.txt
|
||||
## ...
|
||||
|
||||
MODEL_FOR_SEGMENTATION="stt_en_fastconformer_ctc_large"
|
||||
MODEL_FOR_RECOGNITION="stt_en_conformer_ctc_large"
|
||||
## We set this threshold as very permissive, later we will use other metrics for filtering
|
||||
THRESHOLD=-10
|
||||
|
||||
${NEMO_PATH}/tools/ctc_segmentation/run_segmentation.sh \
|
||||
--SCRIPTS_DIR=${NEMO_PATH}/tools/ctc_segmentation/scripts \
|
||||
--MODEL_NAME_OR_PATH=${MODEL_FOR_SEGMENTATION} \
|
||||
--DATA_DIR=${INPUT_DIR}_prepared \
|
||||
--OUTPUT_DIR=${OUTPUT_DIR} \
|
||||
--MIN_SCORE=${THRESHOLD}
|
||||
|
||||
# Thresholds for filtering
|
||||
CER_THRESHOLD=20
|
||||
WER_THRESHOLD=30
|
||||
CER_EDGE_THRESHOLD=30
|
||||
LEN_DIFF_RATIO_THRESHOLD=0.15
|
||||
EDGE_LEN=25
|
||||
BATCH_SIZE=1
|
||||
|
||||
${NEMO_PATH}/tools/ctc_segmentation/run_filter.sh \
|
||||
--SCRIPTS_DIR=${NEMO_PATH}/tools/ctc_segmentation/scripts \
|
||||
--MODEL_NAME_OR_PATH=${MODEL_FOR_RECOGNITION} \
|
||||
--BATCH_SIZE=${BATCH_SIZE} \
|
||||
--MANIFEST=$OUTPUT_DIR/manifests/manifest.json \
|
||||
--INPUT_AUDIO_DIR=${INPUT_DIR}_prepared/audio/ \
|
||||
--EDGE_LEN=${EDGE_LEN} \
|
||||
--CER_THRESHOLD=${CER_THRESHOLD} \
|
||||
--WER_THRESHOLD=${WER_THRESHOLD} \
|
||||
--CER_EDGE_THRESHOLD=${CER_EDGE_THRESHOLD} \
|
||||
--LEN_DIFF_RATIO_THRESHOLD=${LEN_DIFF_RATIO_THRESHOLD}
|
||||
|
||||
python ${NEMO_PATH}/examples/asr/speech_to_text_eval.py \
|
||||
dataset_manifest=${OUTPUT_DIR}/manifests/manifest_transcribed_metrics_filtered.json \
|
||||
use_cer=True \
|
||||
only_score_manifest=True
|
||||
|
||||
python ${NEMO_PATH}/examples/asr/speech_to_text_eval.py \
|
||||
dataset_manifest=${OUTPUT_DIR}/manifests/manifest_transcribed_metrics_filtered.json \
|
||||
use_cer=False \
|
||||
only_score_manifest=True
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
name: "ds_for_fastpitch_align"
|
||||
|
||||
manifest_filepath: "train_manifest.json"
|
||||
sup_data_path: "sup_data"
|
||||
sup_data_types: [ "align_prior_matrix", "pitch", "speaker_id"]
|
||||
phoneme_dict_path: "scripts/tts_dataset_files/zh/24finals/pinyin_dict_nv_22.10.txt"
|
||||
|
||||
dataset:
|
||||
_target_: nemo.collections.tts.data.dataset.TTSDataset
|
||||
manifest_filepath: ${manifest_filepath}
|
||||
sample_rate: 22050
|
||||
sup_data_path: ${sup_data_path}
|
||||
sup_data_types: ${sup_data_types}
|
||||
n_fft: 1024
|
||||
win_length: 1024
|
||||
hop_length: 256
|
||||
window: "hann"
|
||||
n_mels: 80
|
||||
lowfreq: 0
|
||||
highfreq: null
|
||||
max_duration: null
|
||||
min_duration: 0.1
|
||||
ignore_file: null
|
||||
trim: true
|
||||
trim_top_db: 50
|
||||
trim_frame_length: 1024
|
||||
trim_hop_length: 256
|
||||
pitch_fmin: 65.40639132514966
|
||||
pitch_fmax: 2093.004522404789
|
||||
|
||||
text_normalizer:
|
||||
_target_: nemo_text_processing.text_normalization.normalize.Normalizer
|
||||
lang: zh
|
||||
input_case: cased
|
||||
|
||||
text_normalizer_call_kwargs:
|
||||
verbose: false
|
||||
punct_pre_process: true
|
||||
punct_post_process: true
|
||||
|
||||
text_tokenizer:
|
||||
_target_: nemo.collections.common.tokenizers.text_to_speech.tts_tokenizers.ChinesePhonemesTokenizer
|
||||
punct: true
|
||||
apostrophe: true
|
||||
pad_with_space: true
|
||||
g2p:
|
||||
_target_: nemo.collections.tts.g2p.models.zh_cn_pinyin.ChineseG2p
|
||||
phoneme_dict: ${phoneme_dict_path}
|
||||
word_segmenter: jieba # Only jieba is supported now.
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Disclaimer:
|
||||
# Each user is responsible for checking the content of datasets and the applicable licenses and determining if suitable for the intended use.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import tarfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from nemo_text_processing.text_normalization.normalize import Normalizer
|
||||
from opencc import OpenCC
|
||||
|
||||
from nemo.utils.tar_utils import safe_extract
|
||||
|
||||
URL = "https://www.openslr.org/resources/93/data_aishell3.tgz"
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Prepare SF_bilingual dataset and create manifests with predefined split'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--data-root",
|
||||
type=Path,
|
||||
help="where the dataset will reside",
|
||||
default="./DataChinese/sf_bilingual_speech_zh_en_vv1/SF_bilingual/",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifests-path", type=Path, help="where the resulting manifests files will reside", default="./"
|
||||
)
|
||||
parser.add_argument("--val-size", default=0.01, type=float, help="eval set split")
|
||||
parser.add_argument("--test-size", default=0.01, type=float, help="test set split")
|
||||
parser.add_argument(
|
||||
"--seed-for-ds-split",
|
||||
default=100,
|
||||
type=float,
|
||||
help="Seed for deterministic split of train/dev/test, NVIDIA's default is 100",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def __maybe_download_file(source_url, destination_path):
|
||||
if not destination_path.exists():
|
||||
tmp_file_path = destination_path.with_suffix('.tmp')
|
||||
urllib.request.urlretrieve(source_url, filename=str(tmp_file_path))
|
||||
tmp_file_path.rename(destination_path)
|
||||
|
||||
|
||||
def __extract_file(filepath, data_dir):
|
||||
try:
|
||||
with tarfile.open(filepath) as tar:
|
||||
safe_extract(tar, str(data_dir))
|
||||
except Exception:
|
||||
print(f"Error while extracting {filepath}. Already extracted?")
|
||||
|
||||
|
||||
def __process_transcript(file_path: str):
|
||||
# Create directory for processed wav files
|
||||
Path(file_path / "processed").mkdir(parents=True, exist_ok=True)
|
||||
# Create zh-TW to zh-simplify converter
|
||||
cc = OpenCC('t2s')
|
||||
# Create normalizer
|
||||
text_normalizer = Normalizer(
|
||||
lang="zh",
|
||||
input_case="cased",
|
||||
overwrite_cache=True,
|
||||
cache_dir=str(file_path / "cache_dir"),
|
||||
)
|
||||
text_normalizer_call_kwargs = {"punct_pre_process": True, "punct_post_process": True}
|
||||
normalizer_call = lambda x: text_normalizer.normalize(x, **text_normalizer_call_kwargs)
|
||||
entries = []
|
||||
SPEAKER_LEN = 7
|
||||
|
||||
candidates = []
|
||||
speakers = set()
|
||||
with open(file_path / "train" / "content.txt", encoding="utf-8") as fin:
|
||||
for line in fin:
|
||||
content = line.split()
|
||||
wav_name, text = content[0], "".join(content[1::2]) + "。"
|
||||
wav_name = wav_name.replace(u'\ufeff', '')
|
||||
speaker = wav_name[:SPEAKER_LEN]
|
||||
speakers.add(speaker)
|
||||
wav_file = file_path / "train" / "wav" / speaker / wav_name
|
||||
assert os.path.exists(wav_file), f"{wav_file} not found!"
|
||||
duration = subprocess.check_output(["soxi", "-D", str(wav_file)])
|
||||
if float(duration) <= 3.0: # filter out wav files shorter than 3 seconds
|
||||
continue
|
||||
processed_file = file_path / "processed" / wav_name
|
||||
# convert wav to mono 22050HZ, 16 bit (as SFSpeech dataset)
|
||||
subprocess.run(["sox", str(wav_file), "-r", "22050", "-c", "1", "-b", "16", str(processed_file)])
|
||||
candidates.append((processed_file, duration, text, speaker))
|
||||
|
||||
# remapping the speakder to speaker_id (start from 1)
|
||||
remapping = {}
|
||||
for index, speaker in enumerate(sorted(speakers)):
|
||||
remapping[speaker] = index + 1
|
||||
|
||||
for processed_file, duration, text, speaker in candidates:
|
||||
simplified_text = cc.convert(text)
|
||||
normalized_text = normalizer_call(simplified_text)
|
||||
entry = {
|
||||
'audio_filepath': os.path.abspath(processed_file),
|
||||
'duration': float(duration),
|
||||
'text': text,
|
||||
'normalized_text': normalized_text,
|
||||
'speaker_raw': speaker,
|
||||
'speaker': remapping[speaker],
|
||||
}
|
||||
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def __process_data(dataset_path, val_size, test_size, seed_for_ds_split, manifests_dir):
|
||||
entries = __process_transcript(dataset_path)
|
||||
|
||||
random.Random(seed_for_ds_split).shuffle(entries)
|
||||
|
||||
train_size = 1.0 - val_size - test_size
|
||||
train_entries, validate_entries, test_entries = np.split(
|
||||
entries, [int(len(entries) * train_size), int(len(entries) * (train_size + val_size))]
|
||||
)
|
||||
|
||||
assert len(train_entries) > 0, "Not enough data for train, val and test"
|
||||
|
||||
def save(p, data):
|
||||
with open(p, 'w') as f:
|
||||
for d in data:
|
||||
f.write(json.dumps(d) + '\n')
|
||||
|
||||
save(manifests_dir / "train_manifest.json", train_entries)
|
||||
save(manifests_dir / "val_manifest.json", validate_entries)
|
||||
save(manifests_dir / "test_manifest.json", test_entries)
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
|
||||
tarred_data_path = args.data_root / "data_aishell3.tgz"
|
||||
|
||||
__maybe_download_file(URL, tarred_data_path)
|
||||
__extract_file(str(tarred_data_path), str(args.data_root))
|
||||
|
||||
__process_data(
|
||||
args.data_root,
|
||||
args.val_size,
|
||||
args.test_size,
|
||||
args.seed_for_ds_split,
|
||||
args.manifests_path,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,230 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script is to compute global and speaker-level feature statistics for a given TTS training manifest.
|
||||
|
||||
This script should be run after compute_features.py as it loads the precomputed feature data.
|
||||
|
||||
$ python <nemo_root_path>/scripts/dataset_processing/tts/compute_feature_stats.py \
|
||||
--feature_config_path=<nemo_root_path>/examples/tts/conf/features/feature_22050.yaml
|
||||
--manifest_path=<data_root_path>/manifest1.json \
|
||||
--manifest_path=<data_root_path>/manifest2.json \
|
||||
--audio_dir=<data_root_path>/audio1 \
|
||||
--audio_dir=<data_root_path>/audio2 \
|
||||
--feature_dir=<data_root_path>/features1 \
|
||||
--feature_dir=<data_root_path>/features2 \
|
||||
--stats_path=<data_root_path>/feature_stats.json
|
||||
|
||||
The output dictionary will contain the feature statistics for every speaker, as well as a "default" entry
|
||||
with the global statistics.
|
||||
|
||||
For example:
|
||||
|
||||
{
|
||||
"default": {
|
||||
"pitch_mean": 100.0,
|
||||
"pitch_std": 50.0,
|
||||
"energy_mean": 7.5,
|
||||
"energy_std": 4.5
|
||||
},
|
||||
"speaker1": {
|
||||
"pitch_mean": 105.0,
|
||||
"pitch_std": 45.0,
|
||||
"energy_mean": 7.0,
|
||||
"energy_std": 5.0
|
||||
},
|
||||
"speaker2": {
|
||||
"pitch_mean": 110.0,
|
||||
"pitch_std": 30.0,
|
||||
"energy_mean": 5.0,
|
||||
"energy_std": 2.5
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
import torch
|
||||
from omegaconf import OmegaConf
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest
|
||||
from nemo.core.classes.common import safe_instantiate
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
description="Compute TTS feature statistics.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--feature_config_path",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to feature config file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest_path",
|
||||
required=True,
|
||||
type=Path,
|
||||
action="append",
|
||||
help="Path(s) to training manifest.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio_dir",
|
||||
required=True,
|
||||
type=Path,
|
||||
action="append",
|
||||
help="Path(s) to base directory with audio data.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--feature_dir",
|
||||
required=True,
|
||||
type=Path,
|
||||
action="append",
|
||||
help="Path(s) to directory where feature data was stored.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--feature_names",
|
||||
default="pitch,energy",
|
||||
type=str,
|
||||
help="Comma separated list of features to process.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mask_field",
|
||||
default="voiced_mask",
|
||||
type=str,
|
||||
help="If provided, stat computation will ignore non-masked frames.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--stats_path",
|
||||
default=Path("feature_stats.json"),
|
||||
type=Path,
|
||||
help="Path to output JSON file with dataset feature statistics.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Whether to overwrite the output stats file if it exists.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def _compute_stats(values: List[torch.Tensor]) -> Tuple[float, float]:
|
||||
values_tensor = torch.cat(values, dim=0)
|
||||
mean = values_tensor.mean().item()
|
||||
std = values_tensor.std(dim=0).item()
|
||||
return mean, std
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
|
||||
feature_config_path = args.feature_config_path
|
||||
manifest_paths = args.manifest_path
|
||||
audio_dirs = args.audio_dir
|
||||
feature_dirs = args.feature_dir
|
||||
feature_name_str = args.feature_names
|
||||
mask_field = args.mask_field
|
||||
stats_path = args.stats_path
|
||||
overwrite = args.overwrite
|
||||
|
||||
if not (len(manifest_paths) == len(audio_dirs) == len(feature_dirs)):
|
||||
raise ValueError(
|
||||
f"Need same number of manifest, audio_dir, and feature_dir. Received: "
|
||||
f"{len(manifest_paths)}, "
|
||||
f"{len(audio_dirs)}, "
|
||||
f"{len(feature_dirs)}"
|
||||
)
|
||||
|
||||
for manifest_path, audio_dir, feature_dir in zip(manifest_paths, audio_dirs, feature_dirs):
|
||||
if not manifest_path.exists():
|
||||
raise ValueError(f"Manifest {manifest_path} does not exist.")
|
||||
|
||||
if not audio_dir.exists():
|
||||
raise ValueError(f"Audio directory {audio_dir} does not exist.")
|
||||
|
||||
if not feature_dir.exists():
|
||||
raise ValueError(
|
||||
f"Feature directory {feature_dir} does not exist. "
|
||||
f"Please check that the path is correct and that you ran compute_features.py"
|
||||
)
|
||||
|
||||
if stats_path.exists():
|
||||
if overwrite:
|
||||
print(f"Will overwrite existing stats path: {stats_path}")
|
||||
else:
|
||||
raise ValueError(f"Stats path already exists: {stats_path}")
|
||||
|
||||
feature_config = OmegaConf.load(feature_config_path)
|
||||
feature_config = safe_instantiate(feature_config)
|
||||
featurizer_dict = feature_config.featurizers
|
||||
|
||||
print(f"Found featurizers for {list(featurizer_dict.keys())}.")
|
||||
featurizers = featurizer_dict.values()
|
||||
|
||||
feature_names = feature_name_str.split(",")
|
||||
# For each feature, we have a dictionary mapping speaker IDs to a list containing all features
|
||||
# for that speaker
|
||||
feature_stats = {name: defaultdict(list) for name in feature_names}
|
||||
|
||||
for manifest_path, audio_dir, feature_dir in zip(manifest_paths, audio_dirs, feature_dirs):
|
||||
entries = read_manifest(manifest_path)
|
||||
|
||||
for entry in tqdm(entries):
|
||||
speaker = entry["speaker"]
|
||||
|
||||
entry_dict = {}
|
||||
for featurizer in featurizers:
|
||||
feature_dict = featurizer.load(manifest_entry=entry, audio_dir=audio_dir, feature_dir=feature_dir)
|
||||
entry_dict.update(feature_dict)
|
||||
|
||||
if mask_field:
|
||||
mask = entry_dict[mask_field]
|
||||
else:
|
||||
mask = None
|
||||
|
||||
for feature_name in feature_names:
|
||||
values = entry_dict[feature_name]
|
||||
if mask is not None:
|
||||
values = values[mask]
|
||||
|
||||
feature_stat_dict = feature_stats[feature_name]
|
||||
feature_stat_dict["default"].append(values)
|
||||
feature_stat_dict[speaker].append(values)
|
||||
|
||||
stat_dict = defaultdict(dict)
|
||||
for feature_name in feature_names:
|
||||
mean_key = f"{feature_name}_mean"
|
||||
std_key = f"{feature_name}_std"
|
||||
feature_stat_dict = feature_stats[feature_name]
|
||||
for speaker_id, values in feature_stat_dict.items():
|
||||
speaker_mean, speaker_std = _compute_stats(values)
|
||||
stat_dict[speaker_id][mean_key] = speaker_mean
|
||||
stat_dict[speaker_id][std_key] = speaker_std
|
||||
|
||||
with open(stats_path, 'w', encoding="utf-8") as stats_f:
|
||||
json.dump(stat_dict, stats_f, indent=4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,130 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script computes features for TTS models prior to training, such as pitch and energy.
|
||||
The resulting features will be stored in the provided 'feature_dir'.
|
||||
|
||||
$ python <nemo_root_path>/scripts/dataset_processing/tts/compute_features.py \
|
||||
--feature_config_path=<nemo_root_path>/examples/tts/conf/features/feature_22050.yaml \
|
||||
--manifest_path=<data_root_path>/manifest.json \
|
||||
--audio_dir=<data_root_path>/audio \
|
||||
--feature_dir=<data_root_path>/features \
|
||||
--overwrite \
|
||||
--num_workers=1
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from joblib import Parallel, delayed
|
||||
from omegaconf import OmegaConf
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest
|
||||
from nemo.core.classes.common import safe_instantiate
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
description="Compute TTS features.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--feature_config_path",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to feature config file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest_path",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to training manifest.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio_dir",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to base directory with audio data.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--feature_dir",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to directory where feature data will be stored.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dedupe_files",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="If given, will only process the first manifest entry found for each audio file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Whether to overwrite existing feature files.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num_workers", default=1, type=int, help="Number of parallel threads to use. If -1 all CPUs are used."
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
feature_config_path = args.feature_config_path
|
||||
manifest_path = args.manifest_path
|
||||
audio_dir = args.audio_dir
|
||||
feature_dir = args.feature_dir
|
||||
dedupe_files = args.dedupe_files
|
||||
overwrite = args.overwrite
|
||||
num_workers = args.num_workers
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise ValueError(f"Manifest {manifest_path} does not exist.")
|
||||
|
||||
if not audio_dir.exists():
|
||||
raise ValueError(f"Audio directory {audio_dir} does not exist.")
|
||||
|
||||
feature_config = OmegaConf.load(feature_config_path)
|
||||
feature_config = safe_instantiate(feature_config)
|
||||
featurizers = feature_config.featurizers
|
||||
|
||||
entries = read_manifest(manifest_path)
|
||||
|
||||
if dedupe_files:
|
||||
final_entries = []
|
||||
audio_filepath_set = set()
|
||||
for entry in entries:
|
||||
audio_filepath = entry["audio_filepath"]
|
||||
if audio_filepath in audio_filepath_set:
|
||||
continue
|
||||
final_entries.append(entry)
|
||||
audio_filepath_set.add(audio_filepath)
|
||||
entries = final_entries
|
||||
|
||||
for feature_name, featurizer in featurizers.items():
|
||||
print(f"Computing: {feature_name}")
|
||||
Parallel(n_jobs=num_workers)(
|
||||
delayed(featurizer.save)(
|
||||
manifest_entry=entry, audio_dir=audio_dir, feature_dir=feature_dir, overwrite=overwrite
|
||||
)
|
||||
for entry in tqdm(entries)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,144 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script is to compute speaker-level statistics, such as pitch mean & standard deviation, for a given
|
||||
TTS training manifest.
|
||||
|
||||
This script should be run after extract_sup_data.py as it uses the precomputed supplemental features.
|
||||
|
||||
$ python <nemo_root_path>/scripts/dataset_processing/tts/compute_speaker_stats.py \
|
||||
--manifest_path=<data_root_path>/fastpitch_manifest.json \
|
||||
--sup_data_path=<data_root_path>/sup_data \
|
||||
--pitch_stats_path=<data_root_path>/pitch_stats.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
import torch
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest
|
||||
from nemo.collections.tts.parts.utils.tts_dataset_utils import get_base_dir
|
||||
from nemo.collections.tts.torch.tts_data_types import Pitch
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
description="Compute speaker level pitch statistics.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest_path",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to training manifest.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sup_data_path",
|
||||
default=Path("sup_data"),
|
||||
type=Path,
|
||||
help="Path to base directory with supplementary data.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pitch_stats_path",
|
||||
default=Path("pitch_stats.json"),
|
||||
type=Path,
|
||||
help="Path to output JSON file with speaker pitch statistics.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def _compute_stats(values: List[torch.Tensor]) -> Tuple[float, float]:
|
||||
values_tensor = torch.cat(values, dim=0)
|
||||
mean = values_tensor.mean().item()
|
||||
std = values_tensor.std(dim=0).item()
|
||||
return mean, std
|
||||
|
||||
|
||||
def _get_sup_data_filepath(manifest_entry: dict, audio_dir: Path, sup_data_dir: Path) -> Path:
|
||||
"""
|
||||
Get the absolute path of a supplementary data type for the input manifest entry.
|
||||
|
||||
Example: audio_filepath "<audio_dir>/speaker1/audio1.wav" becomes "<sup_data_dir>/speaker1_audio1.pt"
|
||||
|
||||
Args:
|
||||
manifest_entry: Manifest entry dictionary.
|
||||
audio_dir: base directory where audio is stored.
|
||||
sup_data_dir: base directory where supplementary data is stored.
|
||||
|
||||
Returns:
|
||||
Path to the supplementary data file.
|
||||
"""
|
||||
audio_path = Path(manifest_entry["audio_filepath"])
|
||||
rel_audio_path = audio_path.relative_to(audio_dir)
|
||||
rel_sup_data_path = rel_audio_path.with_suffix(".pt")
|
||||
sup_data_filename = str(rel_sup_data_path).replace(os.sep, "_")
|
||||
sup_data_filepath = sup_data_dir / sup_data_filename
|
||||
return sup_data_filepath
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
manifest_path = args.manifest_path
|
||||
sup_data_path = args.sup_data_path
|
||||
pitch_stats_path = args.pitch_stats_path
|
||||
|
||||
pitch_data_path = Path(os.path.join(sup_data_path, Pitch.name))
|
||||
if not os.path.exists(pitch_data_path):
|
||||
raise ValueError(
|
||||
f"Pitch directory {pitch_data_path} does not exist. Make sure 'sup_data_path' is correct "
|
||||
f"and that you have computed the pitch using extract_sup_data.py"
|
||||
)
|
||||
|
||||
entries = read_manifest(manifest_path)
|
||||
|
||||
audio_paths = [entry["audio_filepath"] for entry in entries]
|
||||
base_dir = get_base_dir(audio_paths)
|
||||
|
||||
global_pitch_values = []
|
||||
speaker_pitch_values = defaultdict(list)
|
||||
for entry in tqdm(entries):
|
||||
pitch_path = _get_sup_data_filepath(manifest_entry=entry, audio_dir=base_dir, sup_data_dir=pitch_data_path)
|
||||
if not os.path.exists(pitch_path):
|
||||
logging.warning(f"Unable to find pitch file for {entry}")
|
||||
continue
|
||||
|
||||
pitch = torch.load(pitch_path)
|
||||
# Filter out non-speech frames
|
||||
pitch = pitch[pitch != 0]
|
||||
global_pitch_values.append(pitch)
|
||||
if "speaker" in entry:
|
||||
speaker_id = entry["speaker"]
|
||||
speaker_pitch_values[speaker_id].append(pitch)
|
||||
|
||||
global_pitch_mean, global_pitch_std = _compute_stats(global_pitch_values)
|
||||
pitch_stats = {"default": {"pitch_mean": global_pitch_mean, "pitch_std": global_pitch_std}}
|
||||
for speaker_id, pitch_values in speaker_pitch_values.items():
|
||||
pitch_mean, pitch_std = _compute_stats(pitch_values)
|
||||
pitch_stats[speaker_id] = {"pitch_mean": pitch_mean, "pitch_std": pitch_std}
|
||||
|
||||
with open(pitch_stats_path, 'w', encoding="utf-8") as stats_f:
|
||||
json.dump(pitch_stats, stats_f, indent=4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,104 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script takes a list of TTS manifests and creates a JSON mapping the input speaker names to
|
||||
unique indices for multi-speaker TTS training.
|
||||
|
||||
To ensure that speaker names are unique across datasets, it is recommended that you prepend the speaker
|
||||
names in your manifest with the name of the dataset.
|
||||
|
||||
$ python <nemo_root_path>/scripts/dataset_processing/tts/create_speaker_map.py \
|
||||
--manifest_path=manifest1.json \
|
||||
--manifest_path=manifest2.json \
|
||||
--speaker_map_path=speakers.json
|
||||
|
||||
Example output:
|
||||
|
||||
{
|
||||
"vctk_p225": 0,
|
||||
"vctk_p226": 1,
|
||||
"vctk_p227": 2,
|
||||
...
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
description="Create mapping from speaker names to numerical speaker indices.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest_path",
|
||||
required=True,
|
||||
type=Path,
|
||||
action="append",
|
||||
help="Path to training manifest(s).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--speaker_map_path",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path for output speaker index JSON",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Whether to overwrite the output speaker file if it exists.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
manifest_paths = args.manifest_path
|
||||
speaker_map_path = args.speaker_map_path
|
||||
overwrite = args.overwrite
|
||||
|
||||
for manifest_path in manifest_paths:
|
||||
if not manifest_path.exists():
|
||||
raise ValueError(f"Manifest {manifest_path} does not exist.")
|
||||
|
||||
if speaker_map_path.exists():
|
||||
if overwrite:
|
||||
print(f"Will overwrite existing speaker path: {speaker_map_path}")
|
||||
else:
|
||||
raise ValueError(f"Speaker path already exists: {speaker_map_path}")
|
||||
|
||||
speaker_set = set()
|
||||
for manifest_path in manifest_paths:
|
||||
entries = read_manifest(manifest_path)
|
||||
for entry in entries:
|
||||
speaker = str(entry["speaker"])
|
||||
speaker_set.add(speaker)
|
||||
|
||||
speaker_list = list(speaker_set)
|
||||
speaker_list.sort()
|
||||
speaker_index_map = {speaker_list[i]: i for i in range(len(speaker_list))}
|
||||
|
||||
with open(speaker_map_path, 'w', encoding="utf-8") as stats_f:
|
||||
json.dump(speaker_index_map, stats_f, indent=4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,62 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
import torch
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.core.classes.common import safe_instantiate
|
||||
from nemo.core.config import hydra_runner
|
||||
|
||||
|
||||
def get_pitch_stats(pitch_list):
|
||||
pitch_tensor = torch.cat(pitch_list)
|
||||
pitch_mean, pitch_std = pitch_tensor.mean().item(), pitch_tensor.std().item()
|
||||
pitch_min, pitch_max = pitch_tensor.min().item(), pitch_tensor.max().item()
|
||||
print(f"PITCH_MEAN={pitch_mean}, PITCH_STD={pitch_std}")
|
||||
print(f"PITCH_MIN={pitch_min}, PITCH_MAX={pitch_max}")
|
||||
|
||||
|
||||
def preprocess_ds_for_fastpitch_align(dataloader):
|
||||
pitch_list = []
|
||||
for batch in tqdm(dataloader, total=len(dataloader)):
|
||||
audios, audio_lengths, tokens, tokens_lengths, align_prior_matrices, pitches, pitches_lengths, *_ = batch
|
||||
pitch = pitches.squeeze(0)
|
||||
pitch_list.append(pitch[pitch != 0])
|
||||
|
||||
get_pitch_stats(pitch_list)
|
||||
|
||||
|
||||
CFG_NAME2FUNC = {
|
||||
"ds_for_fastpitch_align": preprocess_ds_for_fastpitch_align,
|
||||
"ds_for_mixer_tts": preprocess_ds_for_fastpitch_align,
|
||||
}
|
||||
|
||||
|
||||
@hydra_runner(config_path='ljspeech/ds_conf', config_name='ds_for_fastpitch_align')
|
||||
def main(cfg):
|
||||
dataset = safe_instantiate(cfg.dataset)
|
||||
dataloader = torch.utils.data.DataLoader(
|
||||
dataset=dataset,
|
||||
batch_size=1,
|
||||
collate_fn=dataset._collate_fn,
|
||||
num_workers=cfg.get("dataloader_params", {}).get("num_workers", 4),
|
||||
)
|
||||
|
||||
print(f"Processing {cfg.manifest_filepath}:")
|
||||
CFG_NAME2FUNC[cfg.name](dataloader)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main() # noqa pylint: disable=no-value-for-parameter
|
||||
@@ -0,0 +1,185 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script is to generate mel spectrograms from a Fastpitch model checkpoint. Please see general usage below. It runs
|
||||
on GPUs by default, but you can add `--num-workers 5 --cpu` as an option to run on CPUs.
|
||||
|
||||
$ python scripts/dataset_processing/tts/generate_mels.py \
|
||||
--fastpitch-model-ckpt ./models/fastpitch/multi_spk/FastPitch--val_loss\=1.4473-epoch\=209.ckpt \
|
||||
--input-json-manifests /home/xueyang/HUI-Audio-Corpus-German-clean/test_manifest_text_normed_phonemes.json
|
||||
--output-json-manifest-root /home/xueyang/experiments/multi_spk_tts_de
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from joblib import Parallel, delayed
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.tts.models import FastPitchModel
|
||||
from nemo.collections.tts.parts.utils.tts_dataset_utils import (
|
||||
BetaBinomialInterpolator,
|
||||
beta_binomial_prior_distribution,
|
||||
)
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
description="Generate mel spectrograms with pretrained FastPitch model, and create manifests for finetuning Hifigan.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fastpitch-model-ckpt",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Specify a full path of a fastpitch model checkpoint with the suffix of either .ckpt or .nemo.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-json-manifests",
|
||||
nargs="+",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Specify a full path of a JSON manifest. You could add multiple manifests.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-json-manifest-root",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Specify a full path of output root that would contain new manifests.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-workers",
|
||||
default=-1,
|
||||
type=int,
|
||||
help="Specify the max number of concurrently Python workers processes. "
|
||||
"If -1 all CPUs are used. If 1 no parallel computing is used.",
|
||||
)
|
||||
parser.add_argument("--cpu", action='store_true', default=False, help="Generate mel spectrograms using CPUs.")
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def __load_wav(audio_file):
|
||||
with sf.SoundFile(audio_file, 'r') as f:
|
||||
samples = f.read(dtype='float32')
|
||||
return samples.transpose()
|
||||
|
||||
|
||||
def __generate_mels(entry, spec_model, device, use_beta_binomial_interpolator, mel_root):
|
||||
# Generate a spectrograms (we need to use ground truth alignment for correct matching between audio and mels)
|
||||
audio = __load_wav(entry["audio_filepath"])
|
||||
audio = torch.from_numpy(audio).unsqueeze(0).to(device)
|
||||
audio_len = torch.tensor(audio.shape[1], dtype=torch.long, device=device).unsqueeze(0)
|
||||
|
||||
if spec_model.fastpitch.speaker_emb is not None and "speaker" in entry:
|
||||
speaker = torch.tensor([entry['speaker']]).to(device)
|
||||
else:
|
||||
speaker = None
|
||||
|
||||
with torch.no_grad():
|
||||
if "normalized_text" in entry:
|
||||
text = spec_model.parse(entry["normalized_text"], normalize=False)
|
||||
else:
|
||||
text = spec_model.parse(entry['text'])
|
||||
|
||||
text_len = torch.tensor(text.shape[-1], dtype=torch.long, device=device).unsqueeze(0)
|
||||
spect, spect_len = spec_model.preprocessor(input_signal=audio, length=audio_len)
|
||||
|
||||
# Generate attention prior and spectrogram inputs for HiFi-GAN
|
||||
if use_beta_binomial_interpolator:
|
||||
beta_binomial_interpolator = BetaBinomialInterpolator()
|
||||
attn_prior = (
|
||||
torch.from_numpy(beta_binomial_interpolator(spect_len.item(), text_len.item()))
|
||||
.unsqueeze(0)
|
||||
.to(text.device)
|
||||
)
|
||||
else:
|
||||
attn_prior = (
|
||||
torch.from_numpy(beta_binomial_prior_distribution(text_len.item(), spect_len.item()))
|
||||
.unsqueeze(0)
|
||||
.to(text.device)
|
||||
)
|
||||
|
||||
spectrogram = spec_model.forward(
|
||||
text=text,
|
||||
input_lens=text_len,
|
||||
spec=spect,
|
||||
mel_lens=spect_len,
|
||||
attn_prior=attn_prior,
|
||||
speaker=speaker,
|
||||
)[0]
|
||||
|
||||
save_path = mel_root / f"{Path(entry['audio_filepath']).stem}.npy"
|
||||
np.save(save_path, spectrogram[0].to('cpu').numpy())
|
||||
entry["mel_filepath"] = str(save_path)
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
ckpt_path = args.fastpitch_model_ckpt
|
||||
input_manifest_filepaths = args.input_json_manifests
|
||||
output_json_manifest_root = args.output_json_manifest_root
|
||||
|
||||
mel_root = output_json_manifest_root / "mels"
|
||||
mel_root.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
# load pretrained FastPitch model checkpoint
|
||||
suffix = ckpt_path.suffix
|
||||
if suffix == ".nemo":
|
||||
spec_model = FastPitchModel.restore_from(ckpt_path).eval()
|
||||
elif suffix == ".ckpt":
|
||||
spec_model = FastPitchModel.load_from_checkpoint(ckpt_path).eval()
|
||||
else:
|
||||
raise ValueError(f"Unsupported suffix: {suffix}")
|
||||
if not args.cpu:
|
||||
spec_model.cuda()
|
||||
device = spec_model.device
|
||||
|
||||
use_beta_binomial_interpolator = spec_model.cfg.train_ds.dataset.get("use_beta_binomial_interpolator", False)
|
||||
|
||||
for manifest in input_manifest_filepaths:
|
||||
logging.info(f"Processing {manifest}.")
|
||||
entries = []
|
||||
with open(manifest, "r") as fjson:
|
||||
for line in fjson:
|
||||
entries.append(json.loads(line.strip()))
|
||||
|
||||
if device == "cpu":
|
||||
new_entries = Parallel(n_jobs=args.num_workers)(
|
||||
delayed(__generate_mels)(entry, spec_model, device, use_beta_binomial_interpolator, mel_root)
|
||||
for entry in entries
|
||||
)
|
||||
else:
|
||||
new_entries = []
|
||||
for entry in tqdm(entries):
|
||||
new_entry = __generate_mels(entry, spec_model, device, use_beta_binomial_interpolator, mel_root)
|
||||
new_entries.append(new_entry)
|
||||
|
||||
mel_manifest_path = output_json_manifest_root / f"{manifest.stem}_mel{manifest.suffix}"
|
||||
with open(mel_manifest_path, "w") as fmel:
|
||||
for entry in new_entries:
|
||||
fmel.write(json.dumps(entry) + "\n")
|
||||
logging.info(f"Processing {manifest} is complete --> {mel_manifest_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,123 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import re
|
||||
import tarfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.utils.tar_utils import safe_extract
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(description='Download HiFiTTS and create manifests with predefined split')
|
||||
parser.add_argument(
|
||||
"--data-root",
|
||||
required=True,
|
||||
type=Path,
|
||||
help='Directory into which to download and extract dataset. \{data-root\}/hi_fi_tts_v0 will be created.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--split',
|
||||
type=str,
|
||||
default='all',
|
||||
help='Choose to generate manifest for all or one of (train, test, split), note that this will still download the full dataset.',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
URL = "https://us.openslr.org/resources/109/hi_fi_tts_v0.tar.gz"
|
||||
|
||||
|
||||
def __maybe_download_file(source_url, destination_path):
|
||||
if not destination_path.exists():
|
||||
tmp_file_path = destination_path.with_suffix('.tmp')
|
||||
urllib.request.urlretrieve(source_url, filename=str(tmp_file_path))
|
||||
tmp_file_path.rename(destination_path)
|
||||
|
||||
|
||||
def __extract_file(filepath, data_dir):
|
||||
try:
|
||||
with tarfile.open(filepath) as tar:
|
||||
safe_extract(tar, str(data_dir))
|
||||
except Exception:
|
||||
print(f"Error while extracting {filepath}. Already extracted?")
|
||||
|
||||
|
||||
def __process_data(data_root, filelists):
|
||||
# Create manifests (based on predefined NVIDIA's split)
|
||||
for split in tqdm(filelists):
|
||||
manifest_target = data_root / f"{split}_manifest.json"
|
||||
print(f"Creating manifest for {split}.")
|
||||
|
||||
entries = []
|
||||
for manifest_src in glob.glob(str(data_root / f"*_{split}.json")):
|
||||
try:
|
||||
search_res = re.search('.*\/([0-9]+)_manifest_([a-z]+)_.*.json', manifest_src)
|
||||
speaker_id = search_res.group(1)
|
||||
audio_quality = search_res.group(2)
|
||||
except Exception:
|
||||
print(f"Failed to find speaker id or audio quality for {manifest_src}, check formatting.")
|
||||
continue
|
||||
|
||||
with open(manifest_src, 'r') as f_in:
|
||||
for input_json_entry in f_in:
|
||||
data = json.loads(input_json_entry)
|
||||
|
||||
# Make sure corresponding wavfile exists
|
||||
wav_path = data_root / data['audio_filepath']
|
||||
assert wav_path.exists(), f"{wav_path} does not exist!"
|
||||
|
||||
entry = {
|
||||
'audio_filepath': data['audio_filepath'],
|
||||
'duration': data['duration'],
|
||||
'text': data['text'],
|
||||
'normalized_text': data['text_normalized'],
|
||||
'speaker': int(speaker_id),
|
||||
# Audio_quality is either clean or other.
|
||||
# The clean set includes recordings with high sound-to-noise ratio and wide bandwidth.
|
||||
# The books with noticeable noise or narrow bandwidth are included in the other subset.
|
||||
# Note: some speaker_id's have both clean and other audio quality.
|
||||
'audio_quality': audio_quality,
|
||||
}
|
||||
entries.append(entry)
|
||||
|
||||
with open(manifest_target, 'w') as f_out:
|
||||
for m in entries:
|
||||
f_out.write(json.dumps(m) + '\n')
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
|
||||
split = ['train', 'dev', 'test'] if args.split == 'all' else list(args.split)
|
||||
|
||||
tarred_data_path = args.data_root / "hi_fi_tts_v0.tar.gz"
|
||||
|
||||
__maybe_download_file(URL, tarred_data_path)
|
||||
__extract_file(str(tarred_data_path), str(args.data_root))
|
||||
|
||||
data_root = args.data_root / "hi_fi_tts_v0"
|
||||
__process_data(data_root, split)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,45 @@
|
||||
name: "ds_for_fastpitch_align"
|
||||
|
||||
manifest_filepath: ???
|
||||
sup_data_path: ???
|
||||
sup_data_types: [ "align_prior_matrix", "pitch" ]
|
||||
|
||||
dataset:
|
||||
_target_: nemo.collections.tts.data.dataset.TTSDataset
|
||||
manifest_filepath: ${manifest_filepath}
|
||||
sample_rate: 44100
|
||||
sup_data_path: ${sup_data_path}
|
||||
sup_data_types: ${sup_data_types}
|
||||
n_fft: 2048
|
||||
win_length: 2048
|
||||
hop_length: 512
|
||||
window: "hann"
|
||||
n_mels: 80
|
||||
lowfreq: 0
|
||||
highfreq: null
|
||||
max_duration: 15
|
||||
min_duration: 0.1
|
||||
ignore_file: null
|
||||
trim: false
|
||||
pitch_fmin: 65.40639132514966
|
||||
pitch_fmax: 2093.004522404789
|
||||
use_beta_binomial_interpolator: false
|
||||
|
||||
text_normalizer:
|
||||
_target_: nemo_text_processing.text_normalization.normalize.Normalizer
|
||||
lang: de
|
||||
input_case: cased
|
||||
|
||||
text_normalizer_call_kwargs:
|
||||
verbose: false
|
||||
punct_pre_process: true
|
||||
punct_post_process: true
|
||||
|
||||
text_tokenizer:
|
||||
_target_: nemo.collections.common.tokenizers.text_to_speech.tts_tokenizers.GermanPhonemesTokenizer
|
||||
punct: true
|
||||
apostrophe: true
|
||||
pad_with_space: true
|
||||
|
||||
dataloader_params:
|
||||
num_workers: 12
|
||||
@@ -0,0 +1,334 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
from joblib import Parallel, delayed
|
||||
from tqdm import tqdm
|
||||
|
||||
try:
|
||||
from nemo_text_processing.text_normalization.normalize import Normalizer
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
raise ModuleNotFoundError(
|
||||
"The package `nemo_text_processing` was not installed in this environment. Please refer to"
|
||||
" https://github.com/NVIDIA/NeMo-text-processing and install this package before using "
|
||||
"this script"
|
||||
)
|
||||
|
||||
from nemo.utils import logging
|
||||
|
||||
# full corpus.
|
||||
URLS_FULL = {
|
||||
"Bernd_Ungerer": "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/dataset_full/Bernd_Ungerer.zip",
|
||||
"Eva_K": "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/dataset_full/Eva_K.zip",
|
||||
"Friedrich": "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/dataset_full/Friedrich.zip",
|
||||
"Hokuspokus": "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/dataset_full/Hokuspokus.zip",
|
||||
"Karlsson": "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/dataset_full/Karlsson.zip",
|
||||
"others": "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/dataset_full/others.zip",
|
||||
}
|
||||
URL_STATS_FULL = "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/datasetStatistic.zip"
|
||||
|
||||
# the clean subset of the full corpus.
|
||||
URLS_CLEAN = {
|
||||
"Bernd_Ungerer": "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/dataset_clean/Bernd_Ungerer_Clean.zip",
|
||||
"Eva_K": "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/dataset_clean/Eva_K_Clean.zip",
|
||||
"Friedrich": "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/dataset_clean/Friedrich_Clean.zip",
|
||||
"Hokuspokus": "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/dataset_clean/Hokuspokus_Clean.zip",
|
||||
"Karlsson": "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/dataset_clean/Karlsson_Clean.zip",
|
||||
"others": "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/dataset_clean/others_Clean.zip",
|
||||
}
|
||||
URL_STATS_CLEAN = "https://opendata.iisys.de/opendata/Datasets/HUI-Audio-Corpus-German/datasetStatisticClean.zip"
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
description="Download HUI-Audio-Corpus-German and create manifests with predefined split. "
|
||||
"Please check details about the corpus in https://github.com/iisys-hof/HUI-Audio-Corpus-German.",
|
||||
)
|
||||
parser.add_argument("--data-root", required=True, type=Path, help="where the resulting dataset will reside.")
|
||||
parser.add_argument("--manifests-root", required=True, type=Path, help="where the manifests files will reside.")
|
||||
parser.add_argument("--set-type", default="clean", choices=["full", "clean"], type=str)
|
||||
parser.add_argument("--min-duration", default=0.1, type=float)
|
||||
parser.add_argument("--max-duration", default=15, type=float)
|
||||
parser.add_argument(
|
||||
"--num-workers",
|
||||
default=-1,
|
||||
type=int,
|
||||
help="Specify the max number of concurrently Python workers processes. "
|
||||
"If -1 all CPUs are used. If 1 no parallel computing is used.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--normalize-text",
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Normalize original text and add a new entry 'normalized_text' to .json file if True.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--val-num-utts-per-speaker",
|
||||
default=1,
|
||||
type=int,
|
||||
help="Specify the number of utterances for each speaker in val split. All speakers are covered.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test-num-utts-per-speaker",
|
||||
default=1,
|
||||
type=int,
|
||||
help="Specify the number of utterances for each speaker in test split. All speakers are covered.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed-for-ds-split",
|
||||
default=100,
|
||||
type=float,
|
||||
help="Seed for deterministic split of train/dev/test, NVIDIA's default is 100",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def __maybe_download_file(source_url, destination_path):
|
||||
if not destination_path.exists():
|
||||
logging.info(f"Downloading data: {source_url} --> {destination_path}")
|
||||
tmp_file_path = destination_path.with_suffix(".tmp")
|
||||
urllib.request.urlretrieve(source_url, filename=tmp_file_path)
|
||||
tmp_file_path.rename(destination_path)
|
||||
else:
|
||||
logging.info(f"Skipped downloading data because it exists: {destination_path}")
|
||||
|
||||
|
||||
def __extract_file(filepath, data_dir):
|
||||
logging.info(f"Unzipping data: {filepath} --> {data_dir}")
|
||||
shutil.unpack_archive(filepath, data_dir)
|
||||
logging.info(f"Unzipping data is complete: {filepath}.")
|
||||
|
||||
|
||||
def __save_json(json_file, dict_list):
|
||||
logging.info(f"Saving JSON split to {json_file}.")
|
||||
with open(json_file, "w") as f:
|
||||
for d in dict_list:
|
||||
f.write(json.dumps(d) + "\n")
|
||||
|
||||
|
||||
def __process_data(
|
||||
dataset_path,
|
||||
stat_path_root,
|
||||
speaker_id,
|
||||
min_duration,
|
||||
max_duration,
|
||||
val_size,
|
||||
test_size,
|
||||
seed_for_ds_split,
|
||||
):
|
||||
logging.info(f"Preparing JSON split for speaker {speaker_id}.")
|
||||
# parse statistic.txt
|
||||
stat_path = stat_path_root / "statistic.txt"
|
||||
with open(stat_path, 'r') as fstat:
|
||||
lines = fstat.readlines()
|
||||
num_utts = int(lines[4].strip().split()[-1])
|
||||
hours = round(float(lines[9].strip().split()[-1]) / 3600.0, 2)
|
||||
|
||||
# parse overview.csv to generate JSON splits.
|
||||
overview_path = stat_path_root / "overview.csv"
|
||||
entries = []
|
||||
with open(overview_path, 'r') as foverview:
|
||||
# Let's skip the header
|
||||
foverview.readline()
|
||||
for line in tqdm(foverview):
|
||||
file_stem, duration, *_, text = line.strip().split("|")
|
||||
duration = float(duration)
|
||||
|
||||
# file_stem -> dir_name (e.g. maerchen_01_f000051 -> maerchen)
|
||||
dir_name = "_".join(file_stem.split("_")[:-2])
|
||||
audio_path = dataset_path / dir_name / "wavs" / f"{file_stem}.wav"
|
||||
|
||||
if min_duration <= duration <= max_duration:
|
||||
entry = {
|
||||
"audio_filepath": str(audio_path),
|
||||
"duration": duration,
|
||||
"text": text,
|
||||
"speaker": speaker_id,
|
||||
}
|
||||
entries.append(entry)
|
||||
|
||||
random.Random(seed_for_ds_split).shuffle(entries)
|
||||
train_size = len(entries) - val_size - test_size
|
||||
if train_size <= 0:
|
||||
logging.warning(f"Skipped speaker {speaker_id}. Not enough data for train, val and test.")
|
||||
train, val, test, is_skipped = [], [], [], True
|
||||
else:
|
||||
logging.info(f"Preparing JSON split for speaker {speaker_id} is complete.")
|
||||
train, val, test, is_skipped = (
|
||||
entries[:train_size],
|
||||
entries[train_size : train_size + val_size],
|
||||
entries[train_size + val_size :],
|
||||
False,
|
||||
)
|
||||
|
||||
return {
|
||||
"train": train,
|
||||
"val": val,
|
||||
"test": test,
|
||||
"is_skipped": is_skipped,
|
||||
"hours": hours,
|
||||
"num_utts": num_utts,
|
||||
}
|
||||
|
||||
|
||||
def __text_normalization(json_file, num_workers=-1):
|
||||
text_normalizer_call_kwargs = {
|
||||
"punct_pre_process": True,
|
||||
"punct_post_process": True,
|
||||
}
|
||||
text_normalizer = Normalizer(
|
||||
lang="de",
|
||||
input_case="cased",
|
||||
overwrite_cache=True,
|
||||
cache_dir=str(json_file.parent / "cache_dir"),
|
||||
)
|
||||
|
||||
def normalizer_call(x):
|
||||
return text_normalizer.normalize(x, **text_normalizer_call_kwargs)
|
||||
|
||||
def add_normalized_text(line_dict):
|
||||
normalized_text = normalizer_call(line_dict["text"])
|
||||
line_dict.update({"normalized_text": normalized_text})
|
||||
return line_dict
|
||||
|
||||
logging.info(f"Normalizing text for {json_file}.")
|
||||
with open(json_file, 'r', encoding='utf-8') as fjson:
|
||||
lines = fjson.readlines()
|
||||
# Note: you need to verify which backend works well on your cluster.
|
||||
# backend="loky" is fine on multi-core Ubuntu OS; backend="threading" on Slurm.
|
||||
dict_list = Parallel(n_jobs=num_workers)(
|
||||
delayed(add_normalized_text)(json.loads(line)) for line in tqdm(lines)
|
||||
)
|
||||
|
||||
json_file_text_normed = json_file.parent / f"{json_file.stem}_text_normed{json_file.suffix}"
|
||||
with open(json_file_text_normed, 'w', encoding="utf-8") as fjson_norm:
|
||||
for dct in dict_list:
|
||||
fjson_norm.write(json.dumps(dct) + "\n")
|
||||
logging.info(f"Normalizing text is complete: {json_file} --> {json_file_text_normed}")
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
data_root = args.data_root
|
||||
manifests_root = args.manifests_root
|
||||
set_type = args.set_type
|
||||
|
||||
dataset_root = data_root / f"HUI-Audio-Corpus-German-{set_type}"
|
||||
dataset_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if set_type == "full":
|
||||
data_source = URLS_FULL
|
||||
stats_source = URL_STATS_FULL
|
||||
elif set_type == "clean":
|
||||
data_source = URLS_CLEAN
|
||||
stats_source = URL_STATS_CLEAN
|
||||
else:
|
||||
raise ValueError(f"Unknown {set_type}. Please choose either clean or full.")
|
||||
|
||||
# download and unzip dataset stats
|
||||
zipped_stats_path = dataset_root / Path(stats_source).name
|
||||
__maybe_download_file(stats_source, zipped_stats_path)
|
||||
__extract_file(zipped_stats_path, dataset_root)
|
||||
|
||||
# download datasets
|
||||
# Note: you need to verify which backend works well on your cluster.
|
||||
# backend="loky" is fine on multi-core Ubuntu OS; backend="threading" on Slurm.
|
||||
Parallel(n_jobs=args.num_workers)(
|
||||
delayed(__maybe_download_file)(data_url, dataset_root / Path(data_url).name)
|
||||
for _, data_url in data_source.items()
|
||||
)
|
||||
|
||||
# unzip datasets
|
||||
# Note: you need to verify which backend works well on your cluster.
|
||||
# backend="loky" is fine on multi-core Ubuntu OS; backend="threading" on Slurm.
|
||||
Parallel(n_jobs=args.num_workers)(
|
||||
delayed(__extract_file)(dataset_root / Path(data_url).name, dataset_root)
|
||||
for _, data_url in data_source.items()
|
||||
)
|
||||
|
||||
# generate json files for train/val/test splits
|
||||
stats_path_root = dataset_root / Path(stats_source).stem / "speacker"
|
||||
entries_train, entries_val, entries_test = [], [], []
|
||||
speaker_entries = []
|
||||
num_speakers = 0
|
||||
for child in stats_path_root.iterdir():
|
||||
if child.is_dir():
|
||||
speaker = child.name
|
||||
num_speakers += 1
|
||||
speaker_stats_root = stats_path_root / speaker
|
||||
speaker_data_path = dataset_root / speaker
|
||||
|
||||
logging.info(f"Processing Speaker: {speaker}")
|
||||
results = __process_data(
|
||||
speaker_data_path,
|
||||
speaker_stats_root,
|
||||
num_speakers,
|
||||
args.min_duration,
|
||||
args.max_duration,
|
||||
args.val_num_utts_per_speaker,
|
||||
args.test_num_utts_per_speaker,
|
||||
args.seed_for_ds_split,
|
||||
)
|
||||
|
||||
entries_train.extend(results["train"])
|
||||
entries_val.extend(results["val"])
|
||||
entries_test.extend(results["test"])
|
||||
|
||||
speaker_entry = {
|
||||
"speaker_name": speaker,
|
||||
"speaker_id": num_speakers,
|
||||
"hours": results["hours"],
|
||||
"num_utts": results["num_utts"],
|
||||
"is_skipped": results["is_skipped"],
|
||||
}
|
||||
speaker_entries.append(speaker_entry)
|
||||
|
||||
# shuffle in place across multiple speakers
|
||||
random.Random(args.seed_for_ds_split).shuffle(entries_train)
|
||||
random.Random(args.seed_for_ds_split).shuffle(entries_val)
|
||||
random.Random(args.seed_for_ds_split).shuffle(entries_test)
|
||||
|
||||
# save speaker stats.
|
||||
df = pd.DataFrame.from_records(speaker_entries)
|
||||
df.sort_values(by="hours", ascending=False, inplace=True)
|
||||
spk2id_file_path = manifests_root / "spk2id.csv"
|
||||
df.to_csv(spk2id_file_path, index=False)
|
||||
logging.info(f"Saving Speaker to ID mapping to {spk2id_file_path}.")
|
||||
|
||||
# save json splits.
|
||||
train_json = manifests_root / "train_manifest.json"
|
||||
val_json = manifests_root / "val_manifest.json"
|
||||
test_json = manifests_root / "test_manifest.json"
|
||||
__save_json(train_json, entries_train)
|
||||
__save_json(val_json, entries_val)
|
||||
__save_json(test_json, entries_test)
|
||||
|
||||
# normalize text if requested. New json file, train_manifest_text_normed.json, will be generated.
|
||||
if args.normalize_text:
|
||||
__text_normalization(train_json, args.num_workers)
|
||||
__text_normalization(val_json, args.num_workers)
|
||||
__text_normalization(test_json, args.num_workers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# USAGE: python get_data.py --data-root=<where to put data> --data-set=<datasets_to_download> --num-workers=<number of parallel workers>
|
||||
# where <datasets_to_download> can be: dev_clean, dev_other, test_clean,
|
||||
# test_other, train_clean_100, train_clean_360, train_other_500 or ALL
|
||||
# You can also put more than one data_set comma-separated:
|
||||
# --data-set=dev_clean,train_clean_100
|
||||
import argparse
|
||||
import fnmatch
|
||||
import functools
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.utils.tar_utils import safe_extract
|
||||
|
||||
parser = argparse.ArgumentParser(description='Download LibriTTS and create manifests')
|
||||
parser.add_argument("--data-root", required=True, type=Path)
|
||||
parser.add_argument("--data-sets", default="dev_clean", type=str)
|
||||
parser.add_argument("--num-workers", default=4, type=int)
|
||||
args = parser.parse_args()
|
||||
|
||||
URLS = {
|
||||
'TRAIN_CLEAN_100': "https://www.openslr.org/resources/60/train-clean-100.tar.gz",
|
||||
'TRAIN_CLEAN_360': "https://www.openslr.org/resources/60/train-clean-360.tar.gz",
|
||||
'TRAIN_OTHER_500': "https://www.openslr.org/resources/60/train-other-500.tar.gz",
|
||||
'DEV_CLEAN': "https://www.openslr.org/resources/60/dev-clean.tar.gz",
|
||||
'DEV_OTHER': "https://www.openslr.org/resources/60/dev-other.tar.gz",
|
||||
'TEST_CLEAN': "https://www.openslr.org/resources/60/test-clean.tar.gz",
|
||||
'TEST_OTHER': "https://www.openslr.org/resources/60/test-other.tar.gz",
|
||||
}
|
||||
|
||||
|
||||
def __maybe_download_file(source_url, destination_path):
|
||||
if not destination_path.exists():
|
||||
tmp_file_path = destination_path.with_suffix('.tmp')
|
||||
urllib.request.urlretrieve(source_url, filename=str(tmp_file_path))
|
||||
tmp_file_path.rename(destination_path)
|
||||
|
||||
|
||||
def __extract_file(filepath, data_dir):
|
||||
try:
|
||||
with tarfile.open(filepath) as tar:
|
||||
safe_extract(tar, str(data_dir))
|
||||
except Exception:
|
||||
print(f"Error while extracting {filepath}. Already extracted?")
|
||||
|
||||
|
||||
def __process_transcript(file_path: str):
|
||||
entries = []
|
||||
with open(file_path, encoding="utf-8") as fin:
|
||||
text = fin.readlines()[0].strip()
|
||||
|
||||
# TODO(oktai15): add normalized text via Normalizer/NormalizerWithAudio
|
||||
wav_file = file_path.replace(".normalized.txt", ".wav")
|
||||
speaker_id = file_path.split('/')[-3]
|
||||
assert os.path.exists(wav_file), f"{wav_file} not found!"
|
||||
duration = subprocess.check_output(["soxi", "-D", wav_file])
|
||||
entry = {
|
||||
'audio_filepath': os.path.abspath(wav_file),
|
||||
'duration': float(duration),
|
||||
'text': text,
|
||||
'speaker': int(speaker_id),
|
||||
}
|
||||
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def __process_data(data_folder, manifest_file, num_workers):
|
||||
files = []
|
||||
entries = []
|
||||
|
||||
for root, dirnames, filenames in os.walk(data_folder):
|
||||
# we will use normalized text provided by the original dataset
|
||||
for filename in fnmatch.filter(filenames, '*.normalized.txt'):
|
||||
files.append(os.path.join(root, filename))
|
||||
|
||||
with multiprocessing.Pool(num_workers) as p:
|
||||
processing_func = functools.partial(__process_transcript)
|
||||
results = p.imap(processing_func, files)
|
||||
for result in tqdm(results, total=len(files)):
|
||||
entries.extend(result)
|
||||
|
||||
with open(manifest_file, 'w') as fout:
|
||||
for m in entries:
|
||||
fout.write(json.dumps(m) + '\n')
|
||||
|
||||
|
||||
def main():
|
||||
data_root = args.data_root
|
||||
data_sets = args.data_sets
|
||||
num_workers = args.num_workers
|
||||
|
||||
if data_sets == "ALL":
|
||||
data_sets = "dev_clean,dev_other,train_clean_100,train_clean_360,train_other_500,test_clean,test_other"
|
||||
if data_sets == "mini":
|
||||
data_sets = "dev_clean,train_clean_100"
|
||||
for data_set in data_sets.split(','):
|
||||
filepath = data_root / f"{data_set}.tar.gz"
|
||||
print(f"Downloading data for {data_set}...")
|
||||
__maybe_download_file(URLS[data_set.upper()], filepath)
|
||||
print("Extracting...")
|
||||
__extract_file(str(filepath), str(data_root))
|
||||
|
||||
print("Processing and building manifest.")
|
||||
__process_data(
|
||||
str(data_root / "LibriTTS" / data_set.replace("_", "-")),
|
||||
str(data_root / "LibriTTS" / f"{data_set}.json"),
|
||||
num_workers=num_workers,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
name: "ds_for_fastpitch_align"
|
||||
|
||||
manifest_filepath: "train_manifest.json"
|
||||
sup_data_path: "sup_data"
|
||||
sup_data_types: [ "align_prior_matrix", "pitch" ]
|
||||
phoneme_dict_path: "scripts/tts_dataset_files/cmudict-0.7b_nv22.10"
|
||||
heteronyms_path: "scripts/tts_dataset_files/heteronyms-052722"
|
||||
|
||||
dataset:
|
||||
_target_: nemo.collections.tts.data.dataset.TTSDataset
|
||||
manifest_filepath: ${manifest_filepath}
|
||||
sample_rate: 22050
|
||||
sup_data_path: ${sup_data_path}
|
||||
sup_data_types: ${sup_data_types}
|
||||
n_fft: 1024
|
||||
win_length: 1024
|
||||
hop_length: 256
|
||||
window: "hann"
|
||||
n_mels: 80
|
||||
lowfreq: 0
|
||||
highfreq: 8000
|
||||
max_duration: null
|
||||
min_duration: 0.1
|
||||
ignore_file: null
|
||||
trim: false
|
||||
pitch_fmin: 65.40639132514966
|
||||
pitch_fmax: 2093.004522404789
|
||||
|
||||
text_normalizer:
|
||||
_target_: nemo_text_processing.text_normalization.normalize.Normalizer
|
||||
lang: en
|
||||
input_case: cased
|
||||
|
||||
text_normalizer_call_kwargs:
|
||||
verbose: false
|
||||
punct_pre_process: true
|
||||
punct_post_process: true
|
||||
|
||||
text_tokenizer:
|
||||
_target_: nemo.collections.common.tokenizers.text_to_speech.tts_tokenizers.EnglishPhonemesTokenizer
|
||||
punct: true
|
||||
stresses: true
|
||||
chars: true
|
||||
apostrophe: true
|
||||
pad_with_space: true
|
||||
g2p:
|
||||
_target_: nemo.collections.tts.g2p.models.en_us_arpabet.EnglishG2p
|
||||
phoneme_dict: ${phoneme_dict_path}
|
||||
heteronyms: ${heteronyms_path}
|
||||
@@ -0,0 +1,49 @@
|
||||
name: "ds_for_mixer_tts"
|
||||
|
||||
manifest_filepath: "train_manifest.json"
|
||||
sup_data_path: "sup_data"
|
||||
sup_data_types: [ "align_prior_matrix", "pitch" ]
|
||||
phoneme_dict_path: "scripts/tts_dataset_files/cmudict-0.7b_nv22.10"
|
||||
heteronyms_path: "scripts/tts_dataset_files/heteronyms-052722"
|
||||
|
||||
dataset:
|
||||
_target_: nemo.collections.tts.data.dataset.TTSDataset
|
||||
manifest_filepath: ${manifest_filepath}
|
||||
sample_rate: 22050
|
||||
sup_data_path: ${sup_data_path}
|
||||
sup_data_types: ${sup_data_types}
|
||||
n_fft: 1024
|
||||
win_length: 1024
|
||||
hop_length: 256
|
||||
window: "hann"
|
||||
n_mels: 80
|
||||
lowfreq: 0
|
||||
highfreq: 8000
|
||||
max_duration: null
|
||||
min_duration: 0.1
|
||||
ignore_file: null
|
||||
trim: false
|
||||
pitch_fmin: 65.40639132514966
|
||||
pitch_fmax: 2093.004522404789
|
||||
|
||||
text_normalizer:
|
||||
_target_: nemo_text_processing.text_normalization.normalize.Normalizer
|
||||
lang: en
|
||||
input_case: cased
|
||||
|
||||
text_normalizer_call_kwargs:
|
||||
verbose: false
|
||||
punct_pre_process: true
|
||||
punct_post_process: true
|
||||
|
||||
text_tokenizer:
|
||||
_target_: nemo.collections.common.tokenizers.text_to_speech.tts_tokenizers.EnglishPhonemesTokenizer
|
||||
punct: true
|
||||
stresses: true
|
||||
chars: true
|
||||
apostrophe: true
|
||||
pad_with_space: true
|
||||
g2p:
|
||||
_target_: nemo.collections.tts.g2p.models.en_us_arpabet.EnglishG2p
|
||||
phoneme_dict: ${phoneme_dict_path}
|
||||
heteronyms: ${heteronyms_path}
|
||||
@@ -0,0 +1,134 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import tarfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.utils.tar_utils import safe_extract
|
||||
|
||||
try:
|
||||
from nemo_text_processing.text_normalization.normalize import Normalizer
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
raise ModuleNotFoundError(
|
||||
"The package `nemo_text_processing` was not installed in this environment. Please refer to"
|
||||
" https://github.com/NVIDIA/NeMo-text-processing and install this package before using "
|
||||
"this script"
|
||||
)
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(description='Download LJSpeech and create manifests with predefined split')
|
||||
parser.add_argument("--data-root", required=True, type=Path)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
URL = "https://data.keithito.com/data/speech/LJSpeech-1.1.tar.bz2"
|
||||
FILELIST_BASE = 'https://raw.githubusercontent.com/NVIDIA/tacotron2/master/filelists'
|
||||
|
||||
|
||||
def _load_sox():
|
||||
try:
|
||||
import sox
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Optional dependency 'sox' is required by this script. Install it with: pip install sox"
|
||||
) from None
|
||||
|
||||
return sox
|
||||
|
||||
|
||||
def __maybe_download_file(source_url, destination_path):
|
||||
if not destination_path.exists():
|
||||
tmp_file_path = destination_path.with_suffix('.tmp')
|
||||
urllib.request.urlretrieve(source_url, filename=str(tmp_file_path))
|
||||
tmp_file_path.rename(destination_path)
|
||||
|
||||
|
||||
def __extract_file(filepath, data_dir):
|
||||
try:
|
||||
with tarfile.open(filepath) as tar:
|
||||
safe_extract(tar, str(data_dir))
|
||||
except Exception:
|
||||
print(f"Error while extracting {filepath}. Already extracted?")
|
||||
|
||||
|
||||
def __process_data(data_root):
|
||||
sox = _load_sox()
|
||||
text_normalizer = Normalizer(
|
||||
lang="en",
|
||||
input_case="cased",
|
||||
overwrite_cache=True,
|
||||
cache_dir=data_root / "cache_dir",
|
||||
)
|
||||
text_normalizer_call_kwargs = {"punct_pre_process": True, "punct_post_process": True}
|
||||
normalizer_call = lambda x: text_normalizer.normalize(x, **text_normalizer_call_kwargs)
|
||||
|
||||
# Create manifests (based on predefined NVIDIA's split)
|
||||
filelists = ['train', 'val', 'test']
|
||||
for split in tqdm(filelists):
|
||||
# Download file list if necessary
|
||||
filelist_path = data_root / f"ljs_audio_text_{split}_filelist.txt"
|
||||
|
||||
if not filelist_path.exists():
|
||||
urllib.request.urlretrieve(
|
||||
f"{FILELIST_BASE}/ljs_audio_text_{split}_filelist.txt",
|
||||
filename=str(filelist_path),
|
||||
)
|
||||
|
||||
manifest_target = data_root / f"{split}_manifest.json"
|
||||
with open(manifest_target, 'w') as f_out:
|
||||
with open(filelist_path, 'r') as filelist:
|
||||
print(f"\nCreating {manifest_target}...")
|
||||
for line in tqdm(filelist):
|
||||
basename = line[6:16]
|
||||
|
||||
text = line[21:].strip()
|
||||
norm_text = normalizer_call(text)
|
||||
|
||||
# Make sure corresponding wavfile exists
|
||||
wav_path = data_root / 'wavs' / f"{basename}.wav"
|
||||
assert wav_path.exists(), f"{wav_path} does not exist!"
|
||||
|
||||
entry = {
|
||||
'audio_filepath': str(wav_path),
|
||||
'duration': sox.file_info.duration(wav_path),
|
||||
'text': text,
|
||||
'normalized_text': norm_text,
|
||||
}
|
||||
|
||||
f_out.write(json.dumps(entry) + '\n')
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
|
||||
tarred_data_path = args.data_root / "LJSpeech-1.1.tar.bz2"
|
||||
|
||||
__maybe_download_file(URL, tarred_data_path)
|
||||
__extract_file(str(tarred_data_path), str(args.data_root))
|
||||
|
||||
data_root = args.data_root / "LJSpeech-1.1"
|
||||
|
||||
__process_data(data_root)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,21 @@
|
||||
Mr. mister
|
||||
Mrs. misses
|
||||
Dr. doctor
|
||||
Drs. doctors
|
||||
Co. company
|
||||
Lt. lieutenant
|
||||
Sgt. sergeant
|
||||
St. saint
|
||||
Jr. junior
|
||||
Maj. major
|
||||
Hon. honorable
|
||||
Gov. governor
|
||||
Capt. captain
|
||||
Esq. esquire
|
||||
Gen. general
|
||||
Ltd. limited
|
||||
Rev. reverend
|
||||
Col. colonel
|
||||
Mt. mount
|
||||
Ft. fort
|
||||
etc. et cetera
|
||||
|
@@ -0,0 +1,280 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script is used to preprocess audio before TTS model training.
|
||||
|
||||
It can be configured to do several processing steps such as silence trimming, volume normalization,
|
||||
and duration filtering.
|
||||
|
||||
These can be done separately through multiple executions of the script, or all at once to avoid saving
|
||||
too many copies of the same audio.
|
||||
|
||||
Most of these can also be done by the TTS data loader at training time, but doing them ahead of time
|
||||
lets us implement more complex processing, validate the correctness of the output, and save on compute time.
|
||||
|
||||
$ python <nemo_root_path>/scripts/dataset_processing/tts/preprocess_audio.py \
|
||||
--input_manifest="<data_root_path>/manifest.json" \
|
||||
--output_manifest="<data_root_path>/manifest_processed.json" \
|
||||
--input_audio_dir="<data_root_path>/audio" \
|
||||
--output_audio_dir="<data_root_path>/audio_processed" \
|
||||
--num_workers=1 \
|
||||
--trim_config_path="<nemo_root_path>/examples/tts/conf/trim/energy.yaml" \
|
||||
--output_sample_rate=22050 \
|
||||
--output_format=flac \
|
||||
--volume_level=0.95 \
|
||||
--min_duration=0.5 \
|
||||
--max_duration=20.0 \
|
||||
--filter_file="filtered.txt"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
import librosa
|
||||
import soundfile as sf
|
||||
from joblib import Parallel, delayed
|
||||
from omegaconf import OmegaConf
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest, write_manifest
|
||||
from nemo.collections.tts.parts.preprocessing.audio_trimming import AudioTrimmer
|
||||
from nemo.collections.tts.parts.utils.tts_dataset_utils import get_abs_rel_paths, normalize_volume
|
||||
from nemo.core.classes.common import safe_instantiate
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
description="Compute speaker level pitch statistics.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input_manifest",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to input training manifest.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input_audio_dir",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to base directory with audio files.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output_manifest",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to output training manifest with processed audio.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output_audio_dir",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to output directory for audio files.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite_audio",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Whether to reprocess and overwrite existing audio files in output_audio_dir.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite_manifest",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Whether to overwrite the output manifest file if it exists.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num_workers", default=1, type=int, help="Number of parallel threads to use. If -1 all CPUs are used."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trim_config_path",
|
||||
required=False,
|
||||
type=Path,
|
||||
help="Path to config file for nemo.collections.tts.data.audio_trimming.AudioTrimmer",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max_entries", default=0, type=int, help="If provided, maximum number of entries in the manifest to process."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output_sample_rate", default=0, type=int, help="If provided, rate to resample the audio to."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output_format",
|
||||
default="wav",
|
||||
type=str,
|
||||
help="If provided, format output audio will be saved as. If not provided, will keep original format.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--volume_level", default=0.0, type=float, help="If provided, peak volume to normalize audio to."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min_duration", default=0.0, type=float, help="If provided, filter out utterances shorter than min_duration."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max_duration", default=0.0, type=float, help="If provided, filter out utterances longer than max_duration."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--filter_file",
|
||||
required=False,
|
||||
type=Path,
|
||||
help="If provided, output filter_file will contain list of " "utterances filtered out.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def _process_entry(
|
||||
entry: dict,
|
||||
input_audio_dir: Path,
|
||||
output_audio_dir: Path,
|
||||
overwrite_audio: bool,
|
||||
audio_trimmer: AudioTrimmer,
|
||||
output_sample_rate: int,
|
||||
output_format: str,
|
||||
volume_level: float,
|
||||
) -> Tuple[dict, float, float]:
|
||||
audio_filepath = Path(entry["audio_filepath"])
|
||||
|
||||
audio_path, audio_path_rel = get_abs_rel_paths(input_path=audio_filepath, base_path=input_audio_dir)
|
||||
|
||||
if not output_format:
|
||||
output_format = audio_path.suffix
|
||||
|
||||
output_path = output_audio_dir / audio_path_rel
|
||||
output_path = output_path.with_suffix(output_format)
|
||||
output_path.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
if output_path.exists() and not overwrite_audio:
|
||||
original_duration = librosa.get_duration(path=audio_path)
|
||||
output_duration = librosa.get_duration(path=output_path)
|
||||
else:
|
||||
audio, sample_rate = librosa.load(audio_path, sr=None)
|
||||
original_duration = librosa.get_duration(y=audio, sr=sample_rate)
|
||||
if audio_trimmer is not None:
|
||||
audio, start_i, end_i = audio_trimmer.trim_audio(
|
||||
audio=audio, sample_rate=int(sample_rate), audio_id=str(audio_path)
|
||||
)
|
||||
|
||||
if output_sample_rate:
|
||||
audio = librosa.resample(y=audio, orig_sr=sample_rate, target_sr=output_sample_rate)
|
||||
sample_rate = output_sample_rate
|
||||
|
||||
if volume_level:
|
||||
audio = normalize_volume(audio, volume_level=volume_level)
|
||||
|
||||
if audio.size > 0:
|
||||
sf.write(file=output_path, data=audio, samplerate=sample_rate)
|
||||
output_duration = librosa.get_duration(y=audio, sr=sample_rate)
|
||||
else:
|
||||
output_duration = 0.0
|
||||
|
||||
entry["duration"] = round(output_duration, 2)
|
||||
|
||||
if os.path.isabs(audio_filepath):
|
||||
entry["audio_filepath"] = str(output_path)
|
||||
else:
|
||||
output_filepath = audio_path_rel.with_suffix(output_format)
|
||||
entry["audio_filepath"] = str(output_filepath)
|
||||
|
||||
return entry, original_duration, output_duration
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
|
||||
input_manifest_path = args.input_manifest
|
||||
output_manifest_path = args.output_manifest
|
||||
input_audio_dir = args.input_audio_dir
|
||||
output_audio_dir = args.output_audio_dir
|
||||
overwrite_audio = args.overwrite_audio
|
||||
overwrite_manifest = args.overwrite_manifest
|
||||
num_workers = args.num_workers
|
||||
max_entries = args.max_entries
|
||||
output_sample_rate = args.output_sample_rate
|
||||
output_format = args.output_format
|
||||
volume_level = args.volume_level
|
||||
min_duration = args.min_duration
|
||||
max_duration = args.max_duration
|
||||
filter_file = args.filter_file
|
||||
|
||||
if output_manifest_path.exists():
|
||||
if overwrite_manifest:
|
||||
print(f"Will overwrite existing manifest path: {output_manifest_path}")
|
||||
else:
|
||||
raise ValueError(f"Manifest path already exists: {output_manifest_path}")
|
||||
|
||||
if args.trim_config_path:
|
||||
audio_trimmer_config = OmegaConf.load(args.trim_config_path)
|
||||
audio_trimmer = safe_instantiate(audio_trimmer_config)
|
||||
else:
|
||||
audio_trimmer = None
|
||||
|
||||
if output_format:
|
||||
if output_format.upper() not in sf.available_formats():
|
||||
raise ValueError(f"Unsupported output audio format: {output_format}")
|
||||
output_format = f".{output_format}"
|
||||
|
||||
output_audio_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
entries = read_manifest(input_manifest_path)
|
||||
if max_entries:
|
||||
entries = entries[:max_entries]
|
||||
|
||||
# 'threading' backend is required when parallelizing torch models.
|
||||
job_outputs = Parallel(n_jobs=num_workers, backend='threading')(
|
||||
delayed(_process_entry)(
|
||||
entry=entry,
|
||||
input_audio_dir=input_audio_dir,
|
||||
output_audio_dir=output_audio_dir,
|
||||
overwrite_audio=overwrite_audio,
|
||||
audio_trimmer=audio_trimmer,
|
||||
output_sample_rate=output_sample_rate,
|
||||
output_format=output_format,
|
||||
volume_level=volume_level,
|
||||
)
|
||||
for entry in tqdm(entries)
|
||||
)
|
||||
|
||||
output_entries = []
|
||||
filtered_entries = []
|
||||
original_durations = 0.0
|
||||
output_durations = 0.0
|
||||
for output_entry, original_duration, output_duration in job_outputs:
|
||||
original_durations += original_duration
|
||||
|
||||
if (
|
||||
output_duration == 0.0
|
||||
or (min_duration and output_duration < min_duration)
|
||||
or (max_duration and output_duration > max_duration)
|
||||
):
|
||||
if output_duration != original_duration:
|
||||
output_entry["original_duration"] = original_duration
|
||||
filtered_entries.append(output_entry)
|
||||
continue
|
||||
|
||||
output_durations += output_duration
|
||||
output_entries.append(output_entry)
|
||||
|
||||
write_manifest(output_path=output_manifest_path, target_manifest=output_entries, ensure_ascii=False)
|
||||
if filter_file:
|
||||
write_manifest(output_path=str(filter_file), target_manifest=filtered_entries, ensure_ascii=False)
|
||||
|
||||
logging.info(f"Duration of original audio: {original_durations / 3600:.2f} hours")
|
||||
logging.info(f"Duration of processed audio: {output_durations / 3600:.2f} hours")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,183 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script is used to preprocess text before TTS model training. This is needed mainly for text normalization,
|
||||
which is slow to rerun during training.
|
||||
|
||||
The output manifest will be the same as the input manifest but with final text stored in the 'normalized_text' field.
|
||||
|
||||
$ python <nemo_root_path>/scripts/dataset_processing/tts/preprocess_text.py \
|
||||
--input_manifest="<data_root_path>/manifest.json" \
|
||||
--output_manifest="<data_root_path>/manifest_processed.json" \
|
||||
--normalizer_config_path="<nemo_root_path>/examples/tts/conf/text/normalizer_en.yaml" \
|
||||
--lower_case \
|
||||
--num_workers=4 \
|
||||
--joblib_batch_size=16
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from joblib import Parallel, delayed
|
||||
from omegaconf import OmegaConf
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.core.classes.common import safe_instantiate
|
||||
|
||||
try:
|
||||
from nemo_text_processing.text_normalization.normalize import Normalizer
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
raise ModuleNotFoundError(
|
||||
"The package `nemo_text_processing` was not installed in this environment. Please refer to"
|
||||
" https://github.com/NVIDIA/NeMo-text-processing and install this package before using "
|
||||
"this script"
|
||||
)
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest, write_manifest
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
description="Process and normalize text data.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input_manifest",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to input training manifest.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output_manifest",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to output training manifest with processed text.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Whether to overwrite the output manifest file if it exists.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--text_key",
|
||||
default="text",
|
||||
type=str,
|
||||
help="Input text field to normalize.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--normalized_text_key",
|
||||
default="normalized_text",
|
||||
type=str,
|
||||
help="Output field to save normalized text to.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lower_case",
|
||||
action=argparse.BooleanOptionalAction,
|
||||
help="Whether to convert the final text to lower case.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--normalizer_config_path",
|
||||
required=False,
|
||||
type=Path,
|
||||
help="Path to config file for nemo_text_processing.text_normalization.normalize.Normalizer.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num_workers", default=1, type=int, help="Number of parallel threads to use. If -1 all CPUs are used."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--joblib_batch_size", type=int, help="Batch size for joblib workers. Defaults to 'auto' if not provided."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max_entries", default=0, type=int, help="If provided, maximum number of entries in the manifest to process."
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def _process_entry(
|
||||
entry: dict,
|
||||
normalizer: Normalizer,
|
||||
text_key: str,
|
||||
normalized_text_key: str,
|
||||
lower_case: bool,
|
||||
lower_case_norm: bool,
|
||||
) -> dict:
|
||||
text = entry[text_key]
|
||||
|
||||
if normalizer is not None:
|
||||
if lower_case_norm:
|
||||
text = text.lower()
|
||||
text = normalizer.normalize(text, punct_pre_process=True, punct_post_process=True)
|
||||
|
||||
if lower_case:
|
||||
text = text.lower()
|
||||
|
||||
entry[normalized_text_key] = text
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
|
||||
input_manifest_path = args.input_manifest
|
||||
output_manifest_path = args.output_manifest
|
||||
text_key = args.text_key
|
||||
normalized_text_key = args.normalized_text_key
|
||||
lower_case = args.lower_case
|
||||
num_workers = args.num_workers
|
||||
batch_size = args.joblib_batch_size
|
||||
max_entries = args.max_entries
|
||||
overwrite = args.overwrite
|
||||
|
||||
if output_manifest_path.exists():
|
||||
if overwrite:
|
||||
print(f"Will overwrite existing manifest path: {output_manifest_path}")
|
||||
else:
|
||||
raise ValueError(f"Manifest path already exists: {output_manifest_path}")
|
||||
|
||||
if args.normalizer_config_path:
|
||||
normalizer_config = OmegaConf.load(args.normalizer_config_path)
|
||||
normalizer = safe_instantiate(normalizer_config)
|
||||
lower_case_norm = normalizer.input_case == "lower_cased"
|
||||
else:
|
||||
normalizer = None
|
||||
lower_case_norm = False
|
||||
|
||||
entries = read_manifest(input_manifest_path)
|
||||
if max_entries:
|
||||
entries = entries[:max_entries]
|
||||
|
||||
if not batch_size:
|
||||
batch_size = 'auto'
|
||||
|
||||
output_entries = Parallel(n_jobs=num_workers, batch_size=batch_size)(
|
||||
delayed(_process_entry)(
|
||||
entry=entry,
|
||||
normalizer=normalizer,
|
||||
text_key=text_key,
|
||||
normalized_text_key=normalized_text_key,
|
||||
lower_case=lower_case,
|
||||
lower_case_norm=lower_case_norm,
|
||||
)
|
||||
for entry in tqdm(entries)
|
||||
)
|
||||
|
||||
write_manifest(output_path=output_manifest_path, target_manifest=output_entries, ensure_ascii=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,252 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script is a helper for resynthesizing TTS dataset using a pretrained text-to-spectrogram model.
|
||||
Goal of resynthesis (as opposed to text-to-speech) is to use the largest amount of ground-truth features from existing speech data.
|
||||
For example, for resynthesis we want to have the same pitch and durations instead of ones predicted by the model.
|
||||
The results are to be used for some other task: vocoder finetuning, spectrogram enhancer training, etc.
|
||||
|
||||
Let's say we have the following toy dataset:
|
||||
/dataset/manifest.json
|
||||
/dataset/1/foo.wav
|
||||
/dataset/2/bar.wav
|
||||
/dataset/sup_data/pitch/1_foo.pt
|
||||
/dataset/sup_data/pitch/2_bar.pt
|
||||
|
||||
manifest.json has two entries for "/dataset/1/foo.wav" and "/dataset/2/bar.wav"
|
||||
(sup_data folder contains pitch files precomputed during training a FastPitch model on this dataset.)
|
||||
(If you lost your sup_data - don't worry, we use TTSDataset class so they would be created on-the-fly)
|
||||
|
||||
Our script call is
|
||||
$ python scripts/dataset_processing/tts/resynthesize_dataset.py \
|
||||
--model-path ./models/fastpitch/multi_spk/FastPitch--val_loss\=1.4473-epoch\=209.ckpt \
|
||||
--input-json-manifest "/dataset/manifest.json" \
|
||||
--input-sup-data-path "/dataset/sup_data/" \
|
||||
--output-folder "/output/" \
|
||||
--device "cuda:0" \
|
||||
--batch-size 1 \
|
||||
--num-workers 1
|
||||
|
||||
Then we get output dataset with following directory structure:
|
||||
/output/manifest_mel.json
|
||||
/output/mels/foo.npy
|
||||
/output/mels/foo_gt.npy
|
||||
/output/mels/bar.npy
|
||||
/output/mels/bar_gt.npy
|
||||
|
||||
/output/manifest_mel.json has the same entries as /dataset/manifest.json but with new fields for spectrograms.
|
||||
"mel_filepath" is path to the resynthesized spectrogram .npy, "mel_gt_filepath" is path to ground-truth spectrogram .npy
|
||||
|
||||
The output structure is similar to generate_mels.py script for compatibility reasons.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Iterator, List
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from omegaconf import DictConfig, OmegaConf
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest, write_manifest
|
||||
from nemo.collections.tts.models import FastPitchModel
|
||||
from nemo.collections.tts.models.base import SpectrogramGenerator
|
||||
from nemo.collections.tts.parts.utils.helpers import process_batch, to_device_recursive
|
||||
|
||||
|
||||
def chunks(iterable: Iterable, size: int) -> Iterator[List]:
|
||||
# chunks([1, 2, 3, 4, 5], size=2) -> [[1, 2], [3, 4], [5]]
|
||||
# assumes iterable does not have any `None`s
|
||||
args = [iter(iterable)] * size
|
||||
for chunk in itertools.zip_longest(*args, fillvalue=None):
|
||||
chunk = list(item for item in chunk if item is not None)
|
||||
if chunk:
|
||||
yield chunk
|
||||
|
||||
|
||||
def load_model(path: Path, device: torch.device) -> SpectrogramGenerator:
|
||||
model = None
|
||||
if path.suffix == ".nemo":
|
||||
model = SpectrogramGenerator.restore_from(path, map_location=device)
|
||||
elif path.suffix == ".ckpt":
|
||||
model = SpectrogramGenerator.load_from_checkpoint(path, map_location=device)
|
||||
else:
|
||||
raise ValueError(f"Unknown checkpoint type {path.suffix} ({path})")
|
||||
|
||||
return model.eval().to(device)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TTSDatasetResynthesizer:
|
||||
"""
|
||||
Reuses internals of a SpectrogramGenerator to resynthesize dataset using ground truth features.
|
||||
Default setup is FastPitch with learned alignment.
|
||||
If your use case requires different setup, you can either contribute to this script or subclass this class.
|
||||
"""
|
||||
|
||||
model: SpectrogramGenerator
|
||||
device: torch.device
|
||||
|
||||
@torch.no_grad()
|
||||
def resynthesize_batch(self, batch: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Resynthesizes a single batch.
|
||||
Takes a dict with main data and sup data.
|
||||
Outputs a dict with model outputs.
|
||||
"""
|
||||
if not isinstance(self.model, FastPitchModel):
|
||||
raise NotImplementedError(
|
||||
"This script supports only FastPitch. Please implement resynthesizing routine for your desired model."
|
||||
)
|
||||
|
||||
batch = to_device_recursive(batch, self.device)
|
||||
|
||||
mels, mel_lens = self.model.preprocessor(input_signal=batch["audio"], length=batch["audio_lens"])
|
||||
|
||||
reference_audio = batch.get("reference_audio", None)
|
||||
reference_audio_len = batch.get("reference_audio_lens", None)
|
||||
reference_spec, reference_spec_len = None, None
|
||||
if reference_audio is not None:
|
||||
reference_spec, reference_spec_len = self.model.preprocessor(
|
||||
input_signal=reference_audio, length=reference_audio_len
|
||||
)
|
||||
|
||||
outputs_tuple = self.model.forward(
|
||||
text=batch["text"],
|
||||
durs=None,
|
||||
pitch=batch["pitch"],
|
||||
speaker=batch.get("speaker"),
|
||||
pace=1.0,
|
||||
spec=mels,
|
||||
attn_prior=batch.get("attn_prior"),
|
||||
mel_lens=mel_lens,
|
||||
input_lens=batch["text_lens"],
|
||||
reference_spec=reference_spec,
|
||||
reference_spec_lens=reference_spec_len,
|
||||
)
|
||||
names = self.model.fastpitch.output_types.keys()
|
||||
return {"spec": mels, "mel_lens": mel_lens, **dict(zip(names, outputs_tuple))}
|
||||
|
||||
def resynthesized_batches(self) -> Iterator[Dict[str, Any]]:
|
||||
"""
|
||||
Returns a generator of resynthesized batches.
|
||||
Each returned batch is a dict containing main data, sup data, and model output
|
||||
"""
|
||||
self.model.setup_training_data(self.model._cfg["train_ds"])
|
||||
|
||||
for batch_tuple in iter(self.model._train_dl):
|
||||
batch = process_batch(batch_tuple, sup_data_types_set=self.model._train_dl.dataset.sup_data_types)
|
||||
yield self.resynthesize_batch(batch)
|
||||
|
||||
|
||||
def prepare_paired_mel_spectrograms(
|
||||
model_path: Path,
|
||||
input_json_manifest: Path,
|
||||
input_sup_data_path: Path,
|
||||
output_folder: Path,
|
||||
device: torch.device,
|
||||
batch_size: int,
|
||||
num_workers: int,
|
||||
):
|
||||
model = load_model(model_path, device)
|
||||
|
||||
dataset_config_overrides = {
|
||||
"dataset": {
|
||||
"manifest_filepath": str(input_json_manifest.absolute()),
|
||||
"sup_data_path": str(input_sup_data_path.absolute()),
|
||||
},
|
||||
"dataloader_params": {"batch_size": batch_size, "num_workers": num_workers, "shuffle": False},
|
||||
}
|
||||
model._cfg.train_ds = OmegaConf.merge(model._cfg.train_ds, DictConfig(dataset_config_overrides))
|
||||
resynthesizer = TTSDatasetResynthesizer(model, device)
|
||||
|
||||
input_manifest = read_manifest(input_json_manifest)
|
||||
|
||||
output_manifest = []
|
||||
output_json_manifest = output_folder / f"{input_json_manifest.stem}_mel{input_json_manifest.suffix}"
|
||||
output_mels_folder = output_folder / "mels"
|
||||
output_mels_folder.mkdir(exist_ok=True, parents=True)
|
||||
for batch, batch_manifest in tqdm(
|
||||
zip(resynthesizer.resynthesized_batches(), chunks(input_manifest, size=batch_size)), desc="Batch #"
|
||||
):
|
||||
pred_mels = batch["spect"].cpu() # key from fastpitch.output_types
|
||||
true_mels = batch["spec"].cpu() # key from code above
|
||||
mel_lens = batch["mel_lens"].cpu().flatten() # key from code above
|
||||
|
||||
for i, (manifest_entry, length) in enumerate(zip(batch_manifest, mel_lens.tolist())):
|
||||
print(manifest_entry["audio_filepath"])
|
||||
filename = Path(manifest_entry["audio_filepath"]).stem
|
||||
|
||||
# note that lengths match
|
||||
pred_mel = pred_mels[i, :, :length].clone().numpy()
|
||||
true_mel = true_mels[i, :, :length].clone().numpy()
|
||||
|
||||
pred_mel_path = output_mels_folder / f"{filename}.npy"
|
||||
true_mel_path = output_mels_folder / f"{filename}_gt.npy"
|
||||
|
||||
np.save(pred_mel_path, pred_mel)
|
||||
np.save(true_mel_path, true_mel)
|
||||
|
||||
new_manifest_entry = {
|
||||
**manifest_entry,
|
||||
"mel_filepath": str(pred_mel_path),
|
||||
"mel_gt_filepath": str(true_mel_path),
|
||||
}
|
||||
output_manifest.append(new_manifest_entry)
|
||||
|
||||
write_manifest(output_json_manifest, output_manifest, ensure_ascii=False)
|
||||
|
||||
|
||||
def argument_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
description="Resynthesize TTS dataset using a pretrained text-to-spectrogram model",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model-path",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to a checkpoint (either .nemo or .ckpt)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-json-manifest",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to the input JSON manifest",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-sup-data-path",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="sup_data_path for the JSON manifest",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-folder",
|
||||
required=True,
|
||||
type=Path,
|
||||
help="Path to the output folder. Will contain updated manifest and mels/ folder with spectrograms in .npy files",
|
||||
)
|
||||
parser.add_argument("--device", required=True, type=torch.device, help="Device ('cpu', 'cuda:0', ...)")
|
||||
parser.add_argument("--batch-size", required=True, type=int, help="Batch size in the DataLoader")
|
||||
parser.add_argument("--num-workers", required=True, type=int, help="Num workers in the DataLoader")
|
||||
return parser
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
arguments = argument_parser().parse_args()
|
||||
prepare_paired_mel_spectrograms(**vars(arguments))
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
name: "ds_for_fastpitch_align"
|
||||
|
||||
manifest_filepath: "train_manifest.json"
|
||||
sup_data_path: "sup_data"
|
||||
sup_data_types: [ "align_prior_matrix", "pitch" ]
|
||||
phoneme_dict_path: "scripts/tts_dataset_files/zh/24finals/pinyin_dict_nv_22.10.txt"
|
||||
|
||||
dataset:
|
||||
_target_: nemo.collections.tts.data.dataset.TTSDataset
|
||||
manifest_filepath: ${manifest_filepath}
|
||||
sample_rate: 22050
|
||||
sup_data_path: ${sup_data_path}
|
||||
sup_data_types: ${sup_data_types}
|
||||
n_fft: 1024
|
||||
win_length: 1024
|
||||
hop_length: 256
|
||||
window: "hann"
|
||||
n_mels: 80
|
||||
lowfreq: 0
|
||||
highfreq: null
|
||||
max_duration: null
|
||||
min_duration: 0.1
|
||||
ignore_file: null
|
||||
trim: true
|
||||
trim_top_db: 50
|
||||
trim_frame_length: 1024
|
||||
trim_hop_length: 256
|
||||
pitch_fmin: 65.40639132514966
|
||||
pitch_fmax: 2093.004522404789
|
||||
|
||||
text_normalizer:
|
||||
_target_: nemo_text_processing.text_normalization.normalize.Normalizer
|
||||
lang: zh
|
||||
input_case: cased
|
||||
|
||||
text_normalizer_call_kwargs:
|
||||
verbose: false
|
||||
punct_pre_process: true
|
||||
punct_post_process: true
|
||||
|
||||
text_tokenizer:
|
||||
_target_: nemo.collections.common.tokenizers.text_to_speech.tts_tokenizers.ChinesePhonemesTokenizer
|
||||
punct: true
|
||||
apostrophe: true
|
||||
pad_with_space: true
|
||||
g2p:
|
||||
_target_: nemo.collections.tts.g2p.models.zh_cn_pinyin.ChineseG2p
|
||||
phoneme_dict: ${phoneme_dict_path}
|
||||
word_segmenter: jieba # Only jieba is supported now.
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from opencc import OpenCC
|
||||
|
||||
try:
|
||||
from nemo_text_processing.text_normalization.normalize import Normalizer
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
raise ModuleNotFoundError(
|
||||
"The package `nemo_text_processing` was not installed in this environment. Please refer to"
|
||||
" https://github.com/NVIDIA/NeMo-text-processing and install this package before using "
|
||||
"this script"
|
||||
)
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Prepare SF_bilingual dataset and create manifests with predefined split'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--data-root",
|
||||
type=Path,
|
||||
help="where the dataset will reside",
|
||||
default="./DataChinese/sf_bilingual_speech_zh_en_vv1/SF_bilingual/",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifests-path", type=Path, help="where the resulting manifests files will reside", default="./"
|
||||
)
|
||||
parser.add_argument("--val-size", default=0.01, type=float, help="eval set split")
|
||||
parser.add_argument("--test-size", default=0.01, type=float, help="test set split")
|
||||
parser.add_argument(
|
||||
"--seed-for-ds-split",
|
||||
default=100,
|
||||
type=float,
|
||||
help="Seed for deterministic split of train/dev/test, NVIDIA's default is 100",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def __process_transcript(file_path: str):
|
||||
# Create zh-TW to zh-simplify converter
|
||||
cc = OpenCC('t2s')
|
||||
# Create normalizer
|
||||
text_normalizer = Normalizer(
|
||||
lang="zh",
|
||||
input_case="cased",
|
||||
overwrite_cache=True,
|
||||
cache_dir=str(file_path / "cache_dir"),
|
||||
)
|
||||
text_normalizer_call_kwargs = {"punct_pre_process": True, "punct_post_process": True}
|
||||
normalizer_call = lambda x: text_normalizer.normalize(x, **text_normalizer_call_kwargs)
|
||||
entries = []
|
||||
i = 0
|
||||
with open(file_path / "text_SF.txt", encoding="utf-8") as fin:
|
||||
for line in fin:
|
||||
content = line.split()
|
||||
wav_name, text = content[0], "".join(content[1:])
|
||||
wav_name = wav_name.replace(u'\ufeff', '')
|
||||
# WAR: change DL to SF, e.g. real wave file com_SF_ce2727.wav, wav name in text_SF
|
||||
# com_DL_ce2727. It would be fixed through the dataset in the future.
|
||||
wav_name = wav_name.replace('DL', 'SF')
|
||||
wav_file = file_path / "wavs" / (wav_name + ".wav")
|
||||
assert os.path.exists(wav_file), f"{wav_file} not found!"
|
||||
duration = subprocess.check_output(["soxi", "-D", str(wav_file)])
|
||||
simplified_text = cc.convert(text)
|
||||
normalized_text = normalizer_call(simplified_text)
|
||||
entry = {
|
||||
'audio_filepath': os.path.abspath(wav_file),
|
||||
'duration': float(duration),
|
||||
'text': text,
|
||||
'normalized_text': normalized_text,
|
||||
}
|
||||
|
||||
i += 1
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def __process_data(dataset_path, val_size, test_size, seed_for_ds_split, manifests_dir):
|
||||
entries = __process_transcript(dataset_path)
|
||||
|
||||
random.Random(seed_for_ds_split).shuffle(entries)
|
||||
|
||||
train_size = 1.0 - val_size - test_size
|
||||
train_entries, validate_entries, test_entries = np.split(
|
||||
entries, [int(len(entries) * train_size), int(len(entries) * (train_size + val_size))]
|
||||
)
|
||||
|
||||
assert len(train_entries) > 0, "Not enough data for train, val and test"
|
||||
|
||||
def save(p, data):
|
||||
with open(p, 'w') as f:
|
||||
for d in data:
|
||||
f.write(json.dumps(d) + '\n')
|
||||
|
||||
save(manifests_dir / "train_manifest.json", train_entries)
|
||||
save(manifests_dir / "val_manifest.json", validate_entries)
|
||||
save(manifests_dir / "test_manifest.json", test_entries)
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
dataset_root = args.data_root
|
||||
dataset_root.mkdir(parents=True, exist_ok=True)
|
||||
__process_data(
|
||||
dataset_root,
|
||||
args.val_size,
|
||||
args.test_size,
|
||||
args.seed_for_ds_split,
|
||||
args.manifests_path,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
name: "ds_for_fastpitch_align"
|
||||
|
||||
manifest_filepath: "train_manifest.json"
|
||||
sup_data_path: "sup_data"
|
||||
sup_data_types: [ "align_prior_matrix", "pitch" ]
|
||||
|
||||
dataset:
|
||||
_target_: nemo.collections.tts.data.dataset.TTSDataset
|
||||
manifest_filepath: ${manifest_filepath}
|
||||
sample_rate: 22050
|
||||
sup_data_path: ${sup_data_path}
|
||||
sup_data_types: ${sup_data_types}
|
||||
n_fft: 1024
|
||||
win_length: 1024
|
||||
hop_length: 256
|
||||
window: "hann"
|
||||
n_mels: 80
|
||||
lowfreq: 0
|
||||
highfreq: null
|
||||
max_duration: null
|
||||
min_duration: 0.1
|
||||
ignore_file: null
|
||||
trim: true
|
||||
trim_top_db: 50
|
||||
trim_frame_length: ${dataset.win_length}
|
||||
trim_hop_length: ${dataset.hop_length}
|
||||
pitch_fmin: 65.40639132514966
|
||||
pitch_fmax: 2093.004522404789
|
||||
|
||||
text_normalizer:
|
||||
_target_: nemo_text_processing.text_normalization.normalize.Normalizer
|
||||
lang: de
|
||||
input_case: cased
|
||||
|
||||
text_normalizer_call_kwargs:
|
||||
verbose: false
|
||||
punct_pre_process: true
|
||||
punct_post_process: true
|
||||
|
||||
text_tokenizer:
|
||||
_target_: nemo.collections.common.tokenizers.text_to_speech.tts_tokenizers.GermanCharsTokenizer
|
||||
punct: true
|
||||
apostrophe: true
|
||||
pad_with_space: true
|
||||
@@ -0,0 +1,274 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script is used to generate JSON manifests for mel-generator model training. The usage is below.
|
||||
|
||||
$ python scripts/dataset_processing/tts/thorsten_neutral/get_data.py \
|
||||
--data-root ~/experiments/thorsten_neutral \
|
||||
--manifests-root ~/experiments/thorsten_neutral \
|
||||
--data-version "22_10" \
|
||||
--min-duration 0.1 \
|
||||
--normalize-text
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from joblib import Parallel, delayed
|
||||
from tqdm import tqdm
|
||||
|
||||
try:
|
||||
from nemo_text_processing.text_normalization.normalize import Normalizer
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
raise ModuleNotFoundError(
|
||||
"The package `nemo_text_processing` was not installed in this environment. Please refer to"
|
||||
" https://github.com/NVIDIA/NeMo-text-processing and install this package before using "
|
||||
"this script"
|
||||
)
|
||||
|
||||
from nemo.utils import logging
|
||||
|
||||
# Thorsten Müller published two neural voice datasets, 21.02 and 22.10.
|
||||
THORSTEN_NEUTRAL = {
|
||||
"21_02": {
|
||||
"url": "https://zenodo.org/record/5525342/files/thorsten-neutral_v03.tgz?download=1",
|
||||
"dir_name": "thorsten-de_v03",
|
||||
"metadata": ["metadata.csv"],
|
||||
},
|
||||
"22_10": {
|
||||
"url": "https://zenodo.org/record/7265581/files/ThorstenVoice-Dataset_2022.10.zip?download=1",
|
||||
"dir_name": "ThorstenVoice-Dataset_2022.10",
|
||||
"metadata": ["metadata_train.csv", "metadata_dev.csv", "metadata_test.csv"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
description="Download Thorsten Müller's neutral voice dataset and create manifests with predefined split. "
|
||||
"Thorsten Müller published two neural voice datasets, 21.02 and 22.10, where 22.10 provides better "
|
||||
"audio quality. Please choose one of the two for your TTS models. Details about the dataset are "
|
||||
"in https://github.com/thorstenMueller/Thorsten-Voice.",
|
||||
)
|
||||
parser.add_argument("--data-root", required=True, type=Path, help="where the resulting dataset will reside.")
|
||||
parser.add_argument("--manifests-root", required=True, type=Path, help="where the manifests files will reside.")
|
||||
parser.add_argument("--data-version", default="22_10", choices=["21_02", "22_10"], type=str)
|
||||
parser.add_argument("--min-duration", default=0.1, type=float)
|
||||
parser.add_argument("--max-duration", default=float('inf'), type=float)
|
||||
parser.add_argument("--val-size", default=100, type=int)
|
||||
parser.add_argument("--test-size", default=100, type=int)
|
||||
parser.add_argument(
|
||||
"--num-workers",
|
||||
default=-1,
|
||||
type=int,
|
||||
help="Specify the max number of concurrent Python worker processes. "
|
||||
"If -1 all CPUs are used. If 1 no parallel computing is used.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--normalize-text",
|
||||
default=False,
|
||||
action='store_true',
|
||||
help="Normalize original text and add a new entry 'normalized_text' to .json file if True.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--seed-for-ds-split",
|
||||
default=100,
|
||||
type=float,
|
||||
help="Seed for deterministic split of train/dev/test, NVIDIA's default is 100.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
return args
|
||||
|
||||
|
||||
def __maybe_download_file(source_url, destination_path):
|
||||
if not destination_path.exists():
|
||||
logging.info(f"Downloading data: {source_url} --> {destination_path}")
|
||||
tmp_file_path = destination_path.with_suffix(".tmp")
|
||||
urllib.request.urlretrieve(source_url, filename=tmp_file_path)
|
||||
tmp_file_path.rename(destination_path)
|
||||
else:
|
||||
logging.info(f"Skipped downloading data because it exists: {destination_path}")
|
||||
|
||||
|
||||
def __extract_file(filepath, data_dir):
|
||||
logging.info(f"Unzipping data: {filepath} --> {data_dir}")
|
||||
shutil.unpack_archive(filepath, data_dir)
|
||||
logging.info(f"Unzipping data is complete: {filepath}.")
|
||||
|
||||
|
||||
def __save_json(json_file, dict_list):
|
||||
logging.info(f"Saving JSON split to {json_file}.")
|
||||
with open(json_file, "w") as f:
|
||||
for d in dict_list:
|
||||
f.write(json.dumps(d) + "\n")
|
||||
|
||||
|
||||
def __text_normalization(json_file, num_workers=-1):
|
||||
text_normalizer_call_kwargs = {
|
||||
"punct_pre_process": True,
|
||||
"punct_post_process": True,
|
||||
}
|
||||
text_normalizer = Normalizer(
|
||||
lang="de",
|
||||
input_case="cased",
|
||||
overwrite_cache=True,
|
||||
cache_dir=str(json_file.parent / "cache_dir"),
|
||||
)
|
||||
|
||||
def normalizer_call(x):
|
||||
return text_normalizer.normalize(x, **text_normalizer_call_kwargs)
|
||||
|
||||
def add_normalized_text(line_dict):
|
||||
normalized_text = normalizer_call(line_dict["text"])
|
||||
line_dict.update({"normalized_text": normalized_text})
|
||||
return line_dict
|
||||
|
||||
logging.info(f"Normalizing text for {json_file}.")
|
||||
with open(json_file, 'r', encoding='utf-8') as fjson:
|
||||
lines = fjson.readlines()
|
||||
# Note: you need to verify which backend works well on your cluster.
|
||||
# backend="loky" is fine on multi-core Ubuntu OS; backend="threading" on Slurm.
|
||||
dict_list = Parallel(n_jobs=num_workers)(
|
||||
delayed(add_normalized_text)(json.loads(line)) for line in tqdm(lines)
|
||||
)
|
||||
|
||||
json_file_text_normed = json_file.parent / f"{json_file.stem}_text_normed{json_file.suffix}"
|
||||
with open(json_file_text_normed, 'w', encoding="utf-8") as fjson_norm:
|
||||
for dct in dict_list:
|
||||
fjson_norm.write(json.dumps(dct) + "\n")
|
||||
logging.info(f"Normalizing text is complete: {json_file} --> {json_file_text_normed}")
|
||||
|
||||
|
||||
def __process_data(
|
||||
unzipped_dataset_path, metadata, min_duration, max_duration, val_size, test_size, seed_for_ds_split
|
||||
):
|
||||
logging.info("Preparing JSON train/val/test splits.")
|
||||
|
||||
entries = list()
|
||||
not_found_wavs = list()
|
||||
wrong_duration_wavs = list()
|
||||
|
||||
for metadata_fname in metadata:
|
||||
meta_file = unzipped_dataset_path / metadata_fname
|
||||
with open(meta_file, 'r') as fmeta:
|
||||
for line in tqdm(fmeta):
|
||||
items = line.strip().split('|')
|
||||
wav_file_stem, text = items[0], items[1]
|
||||
wav_file = unzipped_dataset_path / "wavs" / f"{wav_file_stem}.wav"
|
||||
|
||||
# skip audios if they do not exist.
|
||||
if not wav_file.exists():
|
||||
not_found_wavs.append(wav_file)
|
||||
logging.warning(f"Skipping {wav_file}: it is not found.")
|
||||
continue
|
||||
|
||||
# skip audios if their duration is out of range.
|
||||
duration = subprocess.check_output(["soxi", "-D", str(wav_file)])
|
||||
duration = float(duration)
|
||||
if min_duration <= duration <= max_duration:
|
||||
entry = {
|
||||
'audio_filepath': str(wav_file),
|
||||
'duration': duration,
|
||||
'text': text,
|
||||
}
|
||||
entries.append(entry)
|
||||
elif duration < min_duration:
|
||||
wrong_duration_wavs.append(wav_file)
|
||||
logging.warning(f"Skipping {wav_file}: it is too short, less than {min_duration} seconds.")
|
||||
continue
|
||||
else:
|
||||
wrong_duration_wavs.append(wav_file)
|
||||
logging.warning(f"Skipping {wav_file}: it is too long, greater than {max_duration} seconds.")
|
||||
continue
|
||||
|
||||
random.Random(seed_for_ds_split).shuffle(entries)
|
||||
train_size = len(entries) - val_size - test_size
|
||||
if train_size <= 0:
|
||||
raise ValueError("Not enough data for the train split.")
|
||||
|
||||
logging.info("Preparing JSON train/val/test splits is complete.")
|
||||
train, val, test = (
|
||||
entries[:train_size],
|
||||
entries[train_size : train_size + val_size],
|
||||
entries[train_size + val_size :],
|
||||
)
|
||||
|
||||
return train, val, test, not_found_wavs, wrong_duration_wavs
|
||||
|
||||
|
||||
def main():
|
||||
args = get_args()
|
||||
data_root = args.data_root
|
||||
manifests_root = args.manifests_root
|
||||
data_version = args.data_version
|
||||
|
||||
dataset_root = data_root / f"ThorstenVoice-Dataset-{data_version}"
|
||||
dataset_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# download and extract dataset
|
||||
dataset_url = THORSTEN_NEUTRAL[data_version]["url"]
|
||||
zipped_dataset_path = dataset_root / Path(dataset_url).name.split("?")[0]
|
||||
__maybe_download_file(dataset_url, zipped_dataset_path)
|
||||
__extract_file(zipped_dataset_path, dataset_root)
|
||||
|
||||
# generate train/dev/test splits
|
||||
unzipped_dataset_path = dataset_root / THORSTEN_NEUTRAL[data_version]["dir_name"]
|
||||
entries_train, entries_val, entries_test, not_found_wavs, wrong_duration_wavs = __process_data(
|
||||
unzipped_dataset_path=unzipped_dataset_path,
|
||||
metadata=THORSTEN_NEUTRAL[data_version]["metadata"],
|
||||
min_duration=args.min_duration,
|
||||
max_duration=args.max_duration,
|
||||
val_size=args.val_size,
|
||||
test_size=args.test_size,
|
||||
seed_for_ds_split=args.seed_for_ds_split,
|
||||
)
|
||||
|
||||
# save json splits.
|
||||
train_json = manifests_root / "train_manifest.json"
|
||||
val_json = manifests_root / "val_manifest.json"
|
||||
test_json = manifests_root / "test_manifest.json"
|
||||
__save_json(train_json, entries_train)
|
||||
__save_json(val_json, entries_val)
|
||||
__save_json(test_json, entries_test)
|
||||
|
||||
# save skipped audios that are not found into a file.
|
||||
if len(not_found_wavs) > 0:
|
||||
skipped_not_found_file = manifests_root / "skipped_not_found_wavs.list"
|
||||
with open(skipped_not_found_file, "w") as f_notfound:
|
||||
for line in not_found_wavs:
|
||||
f_notfound.write(f"{line}\n")
|
||||
|
||||
# save skipped audios that are too short or too long into a file.
|
||||
if len(wrong_duration_wavs) > 0:
|
||||
skipped_wrong_duration_file = manifests_root / "skipped_wrong_duration_wavs.list"
|
||||
with open(skipped_wrong_duration_file, "w") as f_wrong_dur:
|
||||
for line in wrong_duration_wavs:
|
||||
f_wrong_dur.write(f"{line}\n")
|
||||
|
||||
# normalize text if requested. New json file, train_manifest_text_normed.json, will be generated.
|
||||
if args.normalize_text:
|
||||
__text_normalization(train_json, args.num_workers)
|
||||
__text_normalization(val_json, args.num_workers)
|
||||
__text_normalization(test_json, args.num_workers)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Use this script to install KenLM, OpenSeq2Seq decoder, Flashlight decoder, OpenGRM Ngram tool to contaner
|
||||
|
||||
# How to use? Build it from NeMo root folder:
|
||||
# 1. git clone https://github.com/NVIDIA/NeMo.git && cd NeMo
|
||||
# 2. DOCKER_BUILDKIT=1 docker build -t nemo:23.03.1 -f ./scripts/installers/Dockerfile.ngramtools .
|
||||
|
||||
from nvcr.io/nvidia/nemo:23.03
|
||||
|
||||
WORKDIR /workspace/nemo
|
||||
|
||||
COPY scripts/. /workspace/nemo/scripts/
|
||||
|
||||
RUN /bin/bash scripts/asr_language_modeling/ngram_lm/install_beamsearch_decoders.sh
|
||||
|
||||
RUN /bin/bash scripts/installers/install_opengrm.sh
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# This Dockerfile installs the necessary dependencies for streaming speech translation with vLLM.
|
||||
|
||||
ARG BASE_IMAGE=pytorch/pytorch:2.9.0-cuda12.8-cudnn9-runtime
|
||||
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG GIT_URL="https://github.com/NVIDIA-NeMo/NeMo.git"
|
||||
ARG CHECKOUT="main"
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Install dependencies
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
apt-get install -y \
|
||||
libtool libltdl-dev \
|
||||
libsndfile1 sox \
|
||||
libfreetype6 \
|
||||
libsox-fmt-all \
|
||||
swig \
|
||||
ffmpeg \
|
||||
libavdevice-dev \
|
||||
git \
|
||||
build-essential \
|
||||
cmake && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install NeMo
|
||||
RUN git clone --recursive ${GIT_URL} nemo_dir && cd nemo_dir && \
|
||||
git checkout $CHECKOUT && \
|
||||
pip install ".[all]"
|
||||
|
||||
# Install vLLM for streaming speech translation
|
||||
RUN pip install vllm==0.12.0
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
echo "Install latest AIS CLI"
|
||||
AIS_CLI_URL=https://github.com/NVIDIA/aistore/releases/latest/download/ais-linux-amd64.tar.gz
|
||||
|
||||
echo "Download AIS CLI from ${AIS_CLI_URL}"
|
||||
curl -LO ${AIS_CLI_URL}
|
||||
|
||||
echo "Extract"
|
||||
tar -xzvf ais-linux-amd64.tar.gz
|
||||
|
||||
echo "Move to /usr/local/bin/"
|
||||
mv ./ais /usr/local/bin/.
|
||||
|
||||
echo "Cleanup"
|
||||
rm ais-linux-amd64.tar.gz
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
DOCKER=false
|
||||
MAYBE_SUDO=""
|
||||
|
||||
GRAPHVIZ_REPO=https://gitlab.com/graphviz/graphviz.git
|
||||
GRAPHVIZ_LATEST_RELEASE=9.0.0
|
||||
GRAPHVIZ_PY_REPO=https://github.com/xflr6/graphviz
|
||||
GRAPHVIZ_PY_LATEST_RELEASE=448d1a0 # Temporary fix until 0.20.2
|
||||
|
||||
if [[ $* == *--docker* ]]; then
|
||||
echo "Docker installation"
|
||||
DOCKER=true
|
||||
else
|
||||
echo "Local installation"
|
||||
if [[ $(sudo -n -v 2) ]]; then
|
||||
MAYBE_SUDO="sudo"
|
||||
else
|
||||
echo "No sudo detected"
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
$MAYBE_SUDO apt-get update
|
||||
$MAYBE_SUDO apt-get remove -y graphviz
|
||||
pip uninstall -y graphviz
|
||||
if [[ $DOCKER == false ]]; then
|
||||
$MAYBE_SUDO apt-get install -y libtool libltdl-dev automake autoconf bison flex tcl \
|
||||
ghostscript libgd-dev fontconfig libcairo2-dev libpango1.0-dev libgts-dev
|
||||
fi
|
||||
git clone ${GRAPHVIZ_REPO} -b ${GRAPHVIZ_LATEST_RELEASE} && cd graphviz
|
||||
./autogen.sh && ./configure --disable-python --disable-perl
|
||||
$MAYBE_SUDO make -j && $MAYBE_SUDO make install
|
||||
cd .. && $MAYBE_SUDO rm -rf graphviz
|
||||
pip install -v "git+${GRAPHVIZ_PY_REPO}@${GRAPHVIZ_PY_LATEST_RELEASE}#egg=graphviz"
|
||||
} || { echo "graphviz installed with errors! Please check installation manually."; exit 1; }
|
||||
echo "graphviz (re-) installed successfully!"
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
K2_REPO=https://github.com/k2-fsa/k2
|
||||
LATEST_RELEASE=0a09f67 # fix for PyTorch 2.6.0
|
||||
# uncomment the following line after the next k2 version is released (>1.24.4)
|
||||
#LATEST_RELEASE=$(git -c 'versionsort.suffix=-' \
|
||||
# ls-remote --exit-code --refs --sort='version:refname' --tags ${K2_REPO} '*.*' \
|
||||
# | tail --lines=1 \
|
||||
# | cut -d '/' -f 3)
|
||||
# "cut --delimiter '/' --fields 3" doesn't work on macOS, use "-d ... -f ..." instead
|
||||
|
||||
pip install wheel setuptools cmake
|
||||
K2_MAKE_ARGS="-j" pip install -v "git+${K2_REPO}@${LATEST_RELEASE}#egg=k2" || { echo "k2 could not be installed!"; exit 1; }
|
||||
python3 -m k2.version > /dev/null || { echo "k2 installed with errors! Please check installation manually."; exit 1; }
|
||||
echo "k2 installed successfully!"
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# This script install OpenFST and Ngram tools from OpenGRM library
|
||||
# Optionally, you can specify a path where to install it as a first positional argument: scripts/installers/install_opengrm.sh /path/to/install/openfst .
|
||||
# Alternatively, in the Linux Debian you can use: sudo apt install libngram-tools
|
||||
|
||||
DECODERS_PATH=/workspace/nemo/decoders # Path to decoders folder: /workspace/nemo/decoders if you use NeMo/Dockerfile
|
||||
if [ "$#" -eq 1 ]
|
||||
then
|
||||
DECODERS_PATH=$1
|
||||
fi
|
||||
cd $DECODERS_PATH
|
||||
|
||||
# Install OpenGrm OpenFST
|
||||
wget https://www.openfst.org/twiki/pub/FST/FstDownload/openfst-1.8.2.tar.gz && tar xvzf openfst-1.8.2.tar.gz && cd openfst-1.8.2 && ./configure --enable-grm && make -j4 && make -j4 install && cd ..
|
||||
|
||||
# Install OpenGrm Ngram
|
||||
OPENFSTPREFIX=$DECODERS_PATH/openfst-1.8.2/src && wget https://www.opengrm.org/twiki/pub/GRM/NGramDownload/ngram-1.3.14.tar.gz && tar xvzf ngram-1.3.14.tar.gz && cd ngram-1.3.14 && LDFLAGS="-L${OPENFSTPREFIX}/lib" CXXFLAGS="-I${OPENFSTPREFIX}/include" ./configure --prefix ${OPENFSTPREFIX} && make -j4 && make -j4 install && cd ..
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
pip install kaldifst kaldilm riva-asrlib-decoder
|
||||
@@ -0,0 +1,144 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""Script to build and install decoder package.
|
||||
|
||||
It is used by scripts/asr_language_modeling/ngram_lm/install_beamsearch_decoders.sh to install
|
||||
KenLM and OpenSeq2Seq decoder.
|
||||
|
||||
You can set the order of KenLM model by changing -DKENLM_MAX_ORDER=10 argument.
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function
|
||||
|
||||
import argparse
|
||||
import distutils.ccompiler
|
||||
import glob
|
||||
import multiprocessing.pool
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
|
||||
from setuptools import Extension, distutils, setup
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--num_processes", default=1, type=int, help="Number of cpu processes to build package. (default: %(default)d)"
|
||||
)
|
||||
args = parser.parse_known_args()
|
||||
|
||||
# reconstruct sys.argv to pass to setup below
|
||||
sys.argv = [sys.argv[0]] + args[1]
|
||||
|
||||
|
||||
# monkey-patch for parallel compilation
|
||||
# See: https://stackoverflow.com/a/13176803
|
||||
def parallelCCompile(
|
||||
self,
|
||||
sources,
|
||||
output_dir=None,
|
||||
macros=None,
|
||||
include_dirs=None,
|
||||
debug=0,
|
||||
extra_preargs=None,
|
||||
extra_postargs=None,
|
||||
depends=None,
|
||||
):
|
||||
# those lines are copied from distutils.ccompiler.CCompiler directly
|
||||
macros, objects, extra_postargs, pp_opts, build = self._setup_compile(
|
||||
output_dir, macros, include_dirs, sources, depends, extra_postargs
|
||||
)
|
||||
cc_args = self._get_cc_args(pp_opts, debug, extra_preargs)
|
||||
|
||||
# parallel code
|
||||
def _single_compile(obj):
|
||||
try:
|
||||
src, ext = build[obj]
|
||||
except KeyError:
|
||||
return
|
||||
self._compile(obj, src, ext, cc_args, extra_postargs, pp_opts)
|
||||
|
||||
# convert to list, imap is evaluated on-demand
|
||||
thread_pool = multiprocessing.pool.ThreadPool(args[0].num_processes)
|
||||
list(thread_pool.imap(_single_compile, objects))
|
||||
return objects
|
||||
|
||||
|
||||
def compile_test(header, library):
|
||||
dummy_path = os.path.join(os.path.dirname(__file__), "dummy")
|
||||
command = (
|
||||
"bash -c \"g++ -include "
|
||||
+ header
|
||||
+ " -l"
|
||||
+ library
|
||||
+ " -x c++ - <<<'int main() {}' -o "
|
||||
+ dummy_path
|
||||
+ " >/dev/null 2>/dev/null && rm "
|
||||
+ dummy_path
|
||||
+ " 2>/dev/null\""
|
||||
)
|
||||
return os.system(command) == 0
|
||||
|
||||
|
||||
# hack compile to support parallel compiling
|
||||
distutils.ccompiler.CCompiler.compile = parallelCCompile
|
||||
|
||||
FILES = glob.glob('kenlm/util/*.cc') + glob.glob('kenlm/lm/*.cc') + glob.glob('kenlm/util/double-conversion/*.cc')
|
||||
|
||||
FILES += glob.glob('openfst-1.6.3/src/lib/*.cc')
|
||||
|
||||
FILES = [fn for fn in FILES if not (fn.endswith('main.cc') or fn.endswith('test.cc') or fn.endswith('unittest.cc'))]
|
||||
|
||||
LIBS = ['stdc++']
|
||||
if platform.system() != 'Darwin':
|
||||
LIBS.append('rt')
|
||||
|
||||
ARGS = ['-O3', '-DNDEBUG', '-DKENLM_MAX_ORDER=10', '-std=c++11']
|
||||
|
||||
if compile_test('zlib.h', 'z'):
|
||||
ARGS.append('-DHAVE_ZLIB')
|
||||
LIBS.append('z')
|
||||
|
||||
if compile_test('bzlib.h', 'bz2'):
|
||||
ARGS.append('-DHAVE_BZLIB')
|
||||
LIBS.append('bz2')
|
||||
|
||||
if compile_test('lzma.h', 'lzma'):
|
||||
ARGS.append('-DHAVE_XZLIB')
|
||||
LIBS.append('lzma')
|
||||
|
||||
os.system('swig -python -c++ ./decoders.i')
|
||||
|
||||
decoders_module = [
|
||||
Extension(
|
||||
name='_swig_decoders',
|
||||
sources=FILES + glob.glob('*.cxx') + glob.glob('*.cpp'),
|
||||
language='c++',
|
||||
include_dirs=[
|
||||
'.',
|
||||
'kenlm',
|
||||
'openfst-1.6.3/src/include',
|
||||
'ThreadPool',
|
||||
],
|
||||
libraries=LIBS,
|
||||
extra_compile_args=ARGS,
|
||||
)
|
||||
]
|
||||
|
||||
setup(
|
||||
name='ctc_decoders',
|
||||
version='1.1',
|
||||
description="""CTC decoders""",
|
||||
ext_modules=decoders_module,
|
||||
py_modules=['ctc_decoders', 'swig_decoders'],
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
### Offline Preference Alignment (DPO/RPO)
|
||||
|
||||
Code: `nemo/collections/tts/models/magpietts_preference_optimization.py`
|
||||
|
||||
Preference Alignment (DPO/RPO) involves the following steps
|
||||
1) Create a list of text-context pairs for which we will generate preference data.
|
||||
2) For each text-context pair generate multiple audios from a base TTS checkpoint and calculate metrics (CER/SSIM) for each generation.
|
||||
3) Create chosen-rejected pairs from the generated audio.
|
||||
4) Finetune the base TTS checkpoint on the chosen-rejected pairs.
|
||||
|
||||
#### 1. Create text-context pairs
|
||||
We pair a list of challenging texts with context audios from our speech datasets. We add a similar number of regular transcripts our datasets such as LibriTTS paired with random context audios. We also include examples with text contexts. There are other options for generating text-context pairs.
|
||||
|
||||
```
|
||||
python scripts/magpietts/dpo/create_text_contextpairs.py \
|
||||
--challenging_texts /Data/DPOPairsInputData/challenging_texts_nemollm.txt \
|
||||
--regular_texts_for_audiocontext /Data/DPOPairsInputData/regular_texts_for_audiocontext.txt \
|
||||
--regular_texts_for_textcontext /Data/DPOPairsInputData/regular_texts_for_textcontext.txt \
|
||||
--audio_contexts /Data/DPOPairsInputData/audio_context_list.json \
|
||||
--text_contexts /Data/DPOPairsInputData/text_context_list.txt \
|
||||
--output_manifest /Data/DPOPairsInputData/text_context_pairs_v2.json \
|
||||
--nsamples_perpair 6 ;
|
||||
```
|
||||
Each pair is repeated `nsamples_perpair` times which specifies how many samples we want to generate for each pair. The output manifest serves as the input for the next step.
|
||||
|
||||
We can also explore other options for these text-context pairs as well depending on the task.
|
||||
|
||||
#### 2. Generate audios for each text-context pair
|
||||
|
||||
Next, we can generate audios from a base TTS checkpoint using the following command. We pass the `audio_dir` as "/" since our text context pairs contains absolute paths. Model config arguments should be modified accordingly to match the base checkpoint architecture. We can run the below command on cluster to generate audios across multiple nodes. This command saves the generated audios along with the metrics for each generation in the `exp_dir`. Each generated audio file is accompanied with a `.json` file that has the CER/SSIM metrics.
|
||||
|
||||
|
||||
```
|
||||
python examples/tts/magpietts.py \
|
||||
--config-name=magpietts_po_inference \
|
||||
mode=test \
|
||||
batch_size=64 \
|
||||
+init_from_ptl_ckpt=<PATH_TO_MAGPIE_CKPT> \
|
||||
exp_manager.exp_dir=<PO_EXP_DIR> \
|
||||
+test_ds_meta.textcontextpairs.manifest_path=<OUTPUT_MANIFEST_FROM_STEP_1> \
|
||||
+test_ds_meta.textcontextpairs.audio_dir="/" \
|
||||
+test_ds_meta.textcontextpairs.feature_dir="/" \
|
||||
model.model_type="decoder_context_tts" # Change this as needed \
|
||||
model.context_duration_min=5.0 \
|
||||
model.context_duration_max=5.0 \
|
||||
model.use_text_conditioning_encoder=true \
|
||||
model.codecmodel_path=<PATH_TO_CODEC_MODEL> \
|
||||
model.alignment_loss_scale=0.002 \
|
||||
model.prior_scaling_factor=null \
|
||||
model.load_cached_codes_if_available=false
|
||||
```
|
||||
#### 3. Create chosen-rejected pairs from the generations
|
||||
|
||||
Next, we go through the generated audio directory and create chosen-rejected pairs.
|
||||
|
||||
```
|
||||
python scripts/magpietts/dpo/create_preference_pairs.py \
|
||||
--input_manifest <OUTPUT_MANIFEST_FROM_STEP_1> \
|
||||
--generated_audio_dir <PO_EXP_DIR>/MagpieTTS-PO-Infer/version_0/audio \
|
||||
--group_size 6 \
|
||||
--cer_threshold 0.01 \
|
||||
--val_size 256 ;
|
||||
```
|
||||
|
||||
`cer_threshold=0.01` means that filter out pairs in which the chosen CER > 0.01.
|
||||
|
||||
This command should save train and val manifests for DPO finetuning in the base directory of the generated_audio_dir, that is, `<PO_EXP_DIR>/MagpieTTS-PO-Infer/version_0/manifests/`
|
||||
|
||||
#### 4. DPO Finetuning Command
|
||||
|
||||
Finally, we perform DPO finetuning using the following command:
|
||||
|
||||
```
|
||||
python examples/tts/magpietts.py \
|
||||
batch_size=4 \
|
||||
+init_from_ptl_ckpt=<PATH_TO_MAGPIE_CKPT> \
|
||||
+mode="dpo_train" \
|
||||
max_epochs=10 \
|
||||
exp_manager.exp_dir=<PO_EXP_DIR> \
|
||||
exp_manager.checkpoint_callback_params.always_save_nemo=false \
|
||||
model.train_ds.datasets._target_="nemo.collections.tts.data.text_to_speech_dataset.MagpieTTSDatasetDPO" \
|
||||
model.validation_ds.datasets._target_="nemo.collections.tts.data.text_to_speech_dataset.MagpieTTSDatasetDPO" \
|
||||
+train_ds_meta.dpopreftrain.manifest_path="<PO_EXP_DIR>/MagpieTTS-PO-Infer/version_0/manifests/" \
|
||||
+train_ds_meta.dpopreftrain.audio_dir="/" \
|
||||
+train_ds_meta.dpopreftrain.feature_dir="/" \
|
||||
+val_ds_meta.dpoprefval.manifest_path="<PO_EXP_DIR>/MagpieTTS-PO-Infer/version_0/manifests/dpo_val_manifest.json" \
|
||||
+val_ds_meta.dpoprefval.audio_dir="/" \
|
||||
+val_ds_meta.dpoprefval.feature_dir="/" \
|
||||
+model.dpo_beta=0.01 \
|
||||
+model.dpo_sft_loss_weight=0.0 \
|
||||
model.model_type="decoder_context_tts" # Change this as needed \
|
||||
model.context_duration_min=5.0 \
|
||||
model.context_duration_max=5.0 \
|
||||
model.use_text_conditioning_encoder=true \
|
||||
model.codecmodel_path=<PATH_TO_CODEC_MODEL> \
|
||||
model.alignment_loss_scale=0.001 \
|
||||
model.prior_scaling_factor=null \
|
||||
trainer.val_check_interval=200 \
|
||||
trainer.log_every_n_steps=10 \
|
||||
model.optim.lr=2e-7 \
|
||||
~model.optim.sched
|
||||
```
|
||||
|
||||
Note the following overrides in the above command:
|
||||
|
||||
```
|
||||
+mode="dpo_train" \
|
||||
model.train_ds.datasets._target_="nemo.collections.tts.data.text_to_speech_dataset.MagpieTTSDatasetDPO" \
|
||||
model.validation_ds.datasets._target_="nemo.collections.tts.data.text_to_speech_dataset.MagpieTTSDatasetDPO" \
|
||||
```
|
||||
|
||||
Again, our manifest contain absolute paths so we specify `audio_dir="/"` .
|
||||
|
||||
### Online Preference Optimization (GRPO)
|
||||
|
||||
For online preference optmization, process is much simpler.
|
||||
|
||||
1) Create a list of text-context pairs for which we will generate preference data (just one pair for a text-context not repeated).
|
||||
We'll use the same process as above, just set `nsamples_perpair 1` in the command.
|
||||
```
|
||||
python scripts/magpietts/dpo/create_text_contextpairs.py \
|
||||
--challenging_texts /Data/DPOPairsInputData/challenging_texts_nemollm.txt \
|
||||
--regular_texts_for_audiocontext /Data/DPOPairsInputData/regular_texts_for_audiocontext.txt \
|
||||
--regular_texts_for_textcontext /Data/DPOPairsInputData/regular_texts_for_textcontext.txt \
|
||||
--audio_contexts /Data/DPOPairsInputData/audio_context_list.json \
|
||||
--text_contexts /Data/DPOPairsInputData/text_context_list.txt \
|
||||
--output_manifest /Data/DPOPairsInputData/text_context_pairs_v2.json \
|
||||
--nsamples_perpair 1 ;
|
||||
```
|
||||
|
||||
2. Train using GRPO
|
||||
|
||||
To train with GRPO, we use a similar training command as the base model training with a few modifications.
|
||||
|
||||
1. We start from a pretrained checkpoint supplied using `+init_from_ptl_ckpt`
|
||||
2. We add `+mode="onlinepo_train"` to specify preference optimization based training.
|
||||
3. Use a small batch size (bs=2) since we generate `num_generations_per_item` samples per item in the batch and the effective batch size becomes `bs*num_generations_per_item`
|
||||
4. The manifest should contain absolute audio paths and the `audio_dir` is specified as "/" in the `train_ds_meta` command.
|
||||
5. Use the same model specific overrides as the base model (eg. x-attn heads, is_causal, num_layers, local transformer etc).
|
||||
6. Set dropout probs to 0 for all modules - This is especially important if we are not using reference free mode. KL divergence loss becomes very spiky and unstable. Set prob to 0 by `model.decoder.p_dropout=0.0`.
|
||||
7. Dont use attention prior or CTC loss during GRPO.
|
||||
8. Add the following GRPO specific arguments in the training command.
|
||||
|
||||
```
|
||||
+model.grpo_beta=0.0 \ # Coeffecient for KL loss (if not using reference free mode)
|
||||
+model.num_generations_per_item=12 \ # 12 samples generated for each item and we compute reward for each
|
||||
+model.reference_free=true \ # Reference free means we dont use KL loss term. Only optimize for rewards
|
||||
+model.inference_cfg_prob=0.0 \ # fraction of generations generated using CFG. Can set > 0.0 if we want to optimize for both CFG and non CFG modes of generation
|
||||
+model.use_local_transformer_prob=0.5 \ # fraction of generations generated using Local Transformer. Set it between 0.0 and 1.0 to improve both LT outputs and non LT outputs for models with an LT
|
||||
+model.inference_cfg_scale=2.5 \ # CFG scale for samples generated using CFG
|
||||
+model.cer_reward_weight=0.33 \ # weightage of CER reward in the overall reward
|
||||
+model.ssim_reward_weight=0.33 \ # weightage of SSIM reward in the overall reward
|
||||
+model.pesq_reward_weight=0.33 \ # weightage of PESQ reward in the overall reward
|
||||
+model.use_pesq=true \ # set this is true is using pesq reward
|
||||
+model.reward_asr_model="whisper" \ # Use whisper only for multilingual settings, dont specify for English
|
||||
model.cfg_unconditional_prob=0.0 \ # Set this to 0, we dont want want to drop out unconditional input
|
||||
+model.inference_topk=2016 \ # Top-K - Not yet sure if we should use topk=80 or not. top_k 2016 just disable top_k in a way.
|
||||
+model.inference_temperature=0.8 \ # Slightly higher temperature for more variety of generations in preference optimization
|
||||
+model.use_kv_cache_during_online_po=true \ # Use KV caching while generating samples for GRPO
|
||||
+model.loss_type="grpo" \ # can be grpo or dr_grpo. grpo works better in my experiments.
|
||||
+model.scale_rewards=true \ # Whether to divide advantages by std deviation or not (set true for GRPO and false for DR_GRPO)
|
||||
+model.max_decoder_steps=430 \ # Max steps for generation
|
||||
```
|
||||
|
||||
9. We also want to validate more frequently during GRPO since each step takes longer. So we add the following args.
|
||||
```
|
||||
~trainer.check_val_every_n_epoch \
|
||||
+trainer.val_check_interval=50 \
|
||||
```
|
||||
|
||||
10. We use a lower learning rate and save the best checkpoints based on lowest CER on our validation set using:
|
||||
```
|
||||
model.optim.lr=1e-7 \
|
||||
~model.optim.sched \
|
||||
exp_manager.checkpoint_callback_params.monitor="val_cer_gt" \
|
||||
exp_manager.checkpoint_callback_params.mode="min" \
|
||||
```
|
||||
|
||||
11. Specify precision and gradient clipping as necessary
|
||||
```
|
||||
trainer.precision=32 \
|
||||
+trainer.gradient_clip_val=2.5 \
|
||||
```
|
||||
|
||||
|
||||
Below is a sample training command for multilingual GRPO:
|
||||
|
||||
```
|
||||
python examples/tts/magpietts.py \
|
||||
--config-name=magpietts_multilingual_v1 #TODO(blisc) after updating yamls\
|
||||
batch_size=2 \
|
||||
+init_from_ptl_ckpt=<PATH_TO_MAGPIE_CKPT> \
|
||||
+mode="onlinepo_train" \
|
||||
+model.text_tokenizers.chartokenizer._target_=AutoTokenizer # Change this as needed \
|
||||
+model.text_tokenizers.chartokenizer.pretrained_model="google/byt5-small" # Change this as needed \
|
||||
max_epochs=20 \
|
||||
exp_manager.exp_dir=<GRPO_EXP_DIR> \
|
||||
+exp_manager.version=0 \
|
||||
exp_manager.checkpoint_callback_params.always_save_nemo=false \
|
||||
+train_ds_meta.dpopreftrain.manifest_path=<TRAIN_MANIFEST_FROM_STEP_1> \
|
||||
+train_ds_meta.dpopreftrain.audio_dir="/" \
|
||||
+train_ds_meta.dpopreftrain.feature_dir="/" \
|
||||
+train_ds_meta.dpopreftrain.tokenizer_names="[chartokenizer]" #Change this as needed \
|
||||
+val_ds_meta.dpoprefval.manifest_path=<VAL_MANIFEST_FROM_STEP_1> \
|
||||
+val_ds_meta.dpoprefval.audio_dir="/" \
|
||||
+val_ds_meta.dpoprefval.feature_dir="/" \
|
||||
+val_ds_meta.dpoprefval.tokenizer_names="[chartokenizer]" #Change this as needed \
|
||||
+model.grpo_beta=0.0 \
|
||||
+model.num_generations_per_item=12 \
|
||||
+model.reference_free=true \
|
||||
+model.inference_cfg_prob=0.0 \
|
||||
+model.inference_cfg_scale=2.5 \
|
||||
+model.cer_reward_weight=0.33 \
|
||||
+model.ssim_reward_weight=0.33 \
|
||||
+model.pesq_reward_weight=0.33 \
|
||||
+model.use_pesq=true \
|
||||
+model.reward_asr_model="whisper" \
|
||||
model.cfg_unconditional_prob=0.0 \
|
||||
+model.inference_topk=2016 \
|
||||
+model.inference_temperature=0.8 \
|
||||
+model.use_kv_cache_during_online_po=true \
|
||||
+model.loss_type="grpo" \
|
||||
+model.scale_rewards=true \
|
||||
+model.max_decoder_steps=430 \
|
||||
model.model_type="decoder_context_tts" #Change this as needed \
|
||||
model.context_duration_min=5.0 \
|
||||
model.context_duration_max=5.0 \
|
||||
model.decoder.p_dropout=0.0 \
|
||||
model.encoder.p_dropout=0.0 \
|
||||
model.local_transformer_type="autoregressive" #Change this as needed \
|
||||
model.local_transformer_n_layers=1 #Change this as needed \
|
||||
model.local_transformer_n_heads=1 #Change this as needed \
|
||||
model.local_transformer_hidden_dim=256 #Change this as needed \
|
||||
model.use_text_conditioning_encoder=true #Change this as needed \
|
||||
model.codecmodel_path=<PATH_TO_CODEC_MODEL> \
|
||||
model.alignment_loss_scale=0.0 \
|
||||
model.prior_scaling_factor=null \
|
||||
~trainer.check_val_every_n_epoch \
|
||||
+trainer.val_check_interval=50 \
|
||||
trainer.log_every_n_steps=10 \
|
||||
model.optim.lr=1e-7 \
|
||||
~model.optim.sched \
|
||||
exp_manager.checkpoint_callback_params.monitor="val_cer_gt" \
|
||||
exp_manager.checkpoint_callback_params.mode="min" \
|
||||
trainer.precision=32 \
|
||||
+trainer.gradient_clip_val=2.5 \
|
||||
```
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script requires the following updates to lhotse: add `shard_offset` in lhotse's writers.
|
||||
$ pip install git+https://github.com/lhotse-speech/lhotse.git@883c24b5f6cdc4bbc73e89186e99f7907262b59c
|
||||
|
||||
Example of manifest:
|
||||
{
|
||||
"audio_filepath": "train-clean-360/4098/11547/4098_11547_000032_000000.wav",
|
||||
"text": "\"Isn't it?\" queried Theo.",
|
||||
"speaker": "| Language:en Dataset:LibriTTS Speaker:4098 |",
|
||||
"chapter_id": "11547",
|
||||
"utter_id": "000032_000000",
|
||||
"duration": 1.9700416666666667,
|
||||
"normalized_text": "\"Isn't it?\" queried Theo.",
|
||||
"context_speaker_similarity": 0.7800518870353699,
|
||||
"context_audio_filepath": "train-clean-360/4098/11547/4098_11547_000031_000001.wav",
|
||||
"context_audio_duration": 9.45
|
||||
}
|
||||
|
||||
Example usage:
|
||||
python scripts/magpietts/create_lhotse_shar_from_nemo_manifest.py \
|
||||
--manifest-path ${MANIFEST} \
|
||||
--audio-base-dir ${AUDIO_BASE_DIR} \
|
||||
--output-dir ${OUTPUT_DIR} \
|
||||
--num-jobs ${NUM_JOBS} \
|
||||
--processing-chunk-size ${CHUNK_SIZE} \
|
||||
--audio-format ${AUDIO_FORMAT} \
|
||||
--log-level ${LOG_LEVEL} \
|
||||
--shuffle \
|
||||
--shuffle-seed 42 \
|
||||
2>&1 | tee ./log/create_lhotse_shar_from_nemo_manifest.stdout
|
||||
|
||||
Expected output:
|
||||
$ tree ${OUTPUT_DIR}
|
||||
${OUTPUT_DIR}/
|
||||
cuts/
|
||||
cuts.000000.jsonl.gz
|
||||
cuts.000001.jsonl.gz
|
||||
...
|
||||
target_audio/
|
||||
recording.000000.tar
|
||||
recording.000001.tar
|
||||
...
|
||||
context_audio/
|
||||
recording.000000.tar
|
||||
recording.000001.tar
|
||||
...
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from lhotse import AudioSource, MonoCut, Recording, SupervisionSegment, compute_num_samples, fastcopy
|
||||
from lhotse.serialization import load_jsonl
|
||||
from lhotse.shar.writers import AudioTarWriter, JsonlShardWriter
|
||||
from tqdm import tqdm
|
||||
|
||||
NEMO_KEYS_NO_NEED_TO_LOG_IN_CUSTOM_FIELDS_FOR_SUPERVISION = [
|
||||
"audio_filepath",
|
||||
"context_audio_filepath",
|
||||
"text",
|
||||
"offset",
|
||||
"duration",
|
||||
"speaker",
|
||||
]
|
||||
|
||||
|
||||
def to_shar_placeholder(recording: Recording, cut: MonoCut) -> Recording:
|
||||
"""this function is borrowed from lhotse.shar.writers.to_shar_placeholder. The only change for Recording instance is to update the id with cut.id."""
|
||||
return fastcopy(
|
||||
recording,
|
||||
id=cut.id,
|
||||
# Creates a single AudioSource out of multiple ones.
|
||||
sources=[AudioSource(type="shar", channels=recording.channel_ids, source="")],
|
||||
# Removes the transform metadata because they were already executed.
|
||||
transforms=None,
|
||||
duration=cut.duration,
|
||||
num_samples=compute_num_samples(cut.duration, recording.sampling_rate),
|
||||
)
|
||||
|
||||
|
||||
def check_speaker_format(item: str):
|
||||
"""Enforce speaker format like '| Language:en Dataset:HiFiTTS Speaker:9136_other |'"""
|
||||
pattern = r"\| Language:\w+ Dataset:[\w\d\W]+ Speaker:[\w\d\W]+ \|"
|
||||
if not isinstance(item, str):
|
||||
return False
|
||||
return bool(re.match(pattern, item))
|
||||
|
||||
|
||||
def get_recording_id(relative_path: str) -> str:
|
||||
"""Generate a recording ID from the relative audio path."""
|
||||
return "rec-" + relative_path.rsplit(".", 1)[0].replace("/", "-")
|
||||
|
||||
|
||||
def process_manifest_entry(entry: Dict[str, Any], audio_base_dir: Path) -> Tuple[MonoCut, MonoCut] | None:
|
||||
"""
|
||||
Processes a single entry from the NeMo manifest to create Lhotse objects.
|
||||
|
||||
Returns:
|
||||
tuple: (target_cut, context_cut) or None if an error occurs.
|
||||
"""
|
||||
try:
|
||||
# Required fields
|
||||
target_audio_path_relative = entry.get("audio_filepath")
|
||||
context_audio_path_relative = entry.get("context_audio_filepath")
|
||||
target_audio_duration = entry.get("duration")
|
||||
context_audio_duration = entry.get("context_audio_duration")
|
||||
text = entry.get("text")
|
||||
# observed cases when text is empty while normalized_text is not.
|
||||
if not text or not text.strip():
|
||||
text = entry.get("normalized_text")
|
||||
speaker = entry.get("speaker")
|
||||
|
||||
# Check required fields
|
||||
if not all(
|
||||
[
|
||||
target_audio_path_relative,
|
||||
context_audio_path_relative,
|
||||
target_audio_duration,
|
||||
context_audio_duration,
|
||||
text,
|
||||
speaker,
|
||||
]
|
||||
):
|
||||
logging.warning(f"Skipping entry due to missing fields: {entry}")
|
||||
return None
|
||||
|
||||
# Check speaker format
|
||||
if not check_speaker_format(speaker):
|
||||
logging.warning(f"Skipping entry due to incorrect speaker format: {entry}")
|
||||
return None
|
||||
|
||||
target_audio_filepath = audio_base_dir / target_audio_path_relative
|
||||
context_audio_filepath = audio_base_dir / context_audio_path_relative
|
||||
|
||||
if not target_audio_filepath.is_file():
|
||||
logging.warning(
|
||||
f"Skipping entry due to missing target audio file: {target_audio_filepath} from entry: {entry}"
|
||||
)
|
||||
return None
|
||||
if not context_audio_filepath.is_file():
|
||||
logging.warning(
|
||||
f"Skipping entry due to missing context audio file: {context_audio_filepath} from entry: {entry}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Create IDs
|
||||
target_recording_id = get_recording_id(target_audio_path_relative)
|
||||
context_recording_id = get_recording_id(context_audio_path_relative)
|
||||
|
||||
# Create Recordings
|
||||
# TODO: if input is FLAC, then we should set AudioSegment.from_file(int_values=True). Does this applies to lhotse?
|
||||
target_recording = Recording.from_file(target_audio_filepath, recording_id=target_recording_id)
|
||||
context_recording = Recording.from_file(context_audio_filepath, recording_id=context_recording_id)
|
||||
|
||||
# Custom fields exist in manifests, so better to keep them for future usage.
|
||||
custom_fields = {
|
||||
key: val
|
||||
for key, val in entry.items()
|
||||
if key not in NEMO_KEYS_NO_NEED_TO_LOG_IN_CUSTOM_FIELDS_FOR_SUPERVISION
|
||||
}
|
||||
custom_fields["context_recording_id"] = context_recording_id
|
||||
|
||||
# Extract language from speaker string
|
||||
lang_match = re.search(r"Language:(\w+)", speaker)
|
||||
language = lang_match.group(1) if lang_match else None
|
||||
|
||||
# offset in seconds
|
||||
target_offset_in_seconds = entry.get("offset", 0.0)
|
||||
context_offset_in_seconds = entry.get("context_audio_offset", 0.0)
|
||||
|
||||
# Create Supervision for target cut. We constrain one supervision per cut for now.
|
||||
supervision = SupervisionSegment(
|
||||
id=f"sup-{target_recording_id}",
|
||||
recording_id=target_recording_id,
|
||||
start=target_offset_in_seconds,
|
||||
duration=target_audio_duration, # duration from manifest
|
||||
channel=0, # only support mono audio for now
|
||||
text=text,
|
||||
language=language,
|
||||
speaker=speaker,
|
||||
custom=custom_fields,
|
||||
)
|
||||
|
||||
# Create target cut
|
||||
target_cut_id = f"cut-{target_recording_id}-{target_offset_in_seconds:.2f}-{target_audio_duration:.2f}"
|
||||
target_cut = MonoCut(
|
||||
id=target_cut_id,
|
||||
start=target_offset_in_seconds,
|
||||
duration=target_audio_duration,
|
||||
channel=0, # only support mono audio for now
|
||||
recording=target_recording,
|
||||
supervisions=[supervision],
|
||||
)
|
||||
if not math.isclose(target_cut.duration, target_audio_duration, abs_tol=0.1):
|
||||
logging.warning(
|
||||
f"Manifest duration ({target_audio_duration}) differs significantly from cut duration ({target_cut.duration}) for {target_recording_id}. Using cut duration."
|
||||
)
|
||||
target_cut.supervisions[0].duration = target_cut.duration
|
||||
|
||||
# Create context cut. This cut is only used to load segmented audio and would not be stored in the final manifest.
|
||||
context_cut_id = (
|
||||
f"context_cut-{context_recording_id}-{context_offset_in_seconds:.2f}-{context_audio_duration:.2f}"
|
||||
)
|
||||
if context_cut_id.split("-", 1)[1] == target_cut_id.split("-", 1)[1]:
|
||||
logging.warning(f"Context cut has the same recording segment as target cut. Skipping entry: {entry}")
|
||||
return None
|
||||
|
||||
context_cut = MonoCut(
|
||||
id=context_cut_id,
|
||||
start=context_offset_in_seconds,
|
||||
duration=context_audio_duration,
|
||||
channel=0, # only support mono audio for now
|
||||
recording=context_recording,
|
||||
)
|
||||
return target_cut, context_cut
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Skipping entry due to error during metadata processing: {entry}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def shuffle_jsonl_file(input_path: Path, seed: int = None) -> Path:
|
||||
"""
|
||||
Shuffle lines in a JSONL file and write to a shuffled copy.
|
||||
|
||||
Args:
|
||||
input_path: Path to the original JSONL file
|
||||
seed: Random seed for reproducible shuffling
|
||||
|
||||
Returns:
|
||||
Path to the shuffled file
|
||||
"""
|
||||
if seed is not None:
|
||||
random.seed(seed)
|
||||
|
||||
logging.info(f"Reading and shuffling manifest entries from {input_path}")
|
||||
|
||||
# Read all lines into memory
|
||||
with open(input_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
logging.info(f"Loaded {len(lines)} entries, now shuffling...")
|
||||
|
||||
# Shuffle the lines
|
||||
random.shuffle(lines)
|
||||
|
||||
# Create output path with "_shuffled" suffix
|
||||
shuffled_path = input_path.parent / f"{input_path.stem}_shuffled{input_path.suffix}"
|
||||
|
||||
# Write shuffled content
|
||||
with open(shuffled_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
logging.info(f"Shuffled manifest written to: {shuffled_path}")
|
||||
return shuffled_path
|
||||
|
||||
|
||||
def chunked_iterator(iterable, chunk_size):
|
||||
"""Yield successive chunks from iterable."""
|
||||
_it = iter(iterable)
|
||||
while _chunk := tuple(itertools.islice(_it, chunk_size)):
|
||||
yield _chunk
|
||||
|
||||
|
||||
def process_and_write_chunk(
|
||||
manifest_chunk_with_idx: Tuple[int, Tuple[Dict[str, Any], ...]],
|
||||
audio_base_dir: Path,
|
||||
output_dir: Path,
|
||||
audio_format: str,
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Processes a chunk of manifest entries, loads audio, and writes corresponding
|
||||
single shard files for cuts, target audio, and context audio.
|
||||
Designed to be run in a parallel worker process.
|
||||
Loads and writes audio iteratively to save memory.
|
||||
|
||||
Returns a dict containing processing stats like 'processed', 'initial_errors', 'audio_load_errors'.
|
||||
"""
|
||||
chunk_idx, manifest_chunk = manifest_chunk_with_idx
|
||||
worker_pid = os.getpid()
|
||||
logging.debug(f"[Worker {worker_pid}, Chunk {chunk_idx}] Starting processing {len(manifest_chunk)} entries.")
|
||||
|
||||
# --- 1. Process manifest entries to get Cut objects ---
|
||||
chunk_metadata = []
|
||||
initial_errors = 0
|
||||
for entry in manifest_chunk:
|
||||
result = process_manifest_entry(entry, audio_base_dir=audio_base_dir)
|
||||
if result is not None:
|
||||
chunk_metadata.append(result)
|
||||
else:
|
||||
initial_errors += 1
|
||||
|
||||
if not chunk_metadata:
|
||||
logging.warning(
|
||||
f"[Worker {worker_pid}, Chunk {chunk_idx}] No valid entries after initial processing. Skipping."
|
||||
)
|
||||
return {"processed": 0, "initial_errors": initial_errors, "audio_load_errors": 0, "write_errors": 0}
|
||||
|
||||
logging.debug(
|
||||
f"[Worker {worker_pid}, Chunk {chunk_idx}] Collected {len(chunk_metadata)} cut pairs after initial processing."
|
||||
)
|
||||
|
||||
# --- 2. Initialize writers and perform iterative load-and-write ---
|
||||
cuts_dir = output_dir / "cuts"
|
||||
target_recordings_dir = output_dir / "target_audio"
|
||||
context_recordings_dir = output_dir / "context_audio"
|
||||
|
||||
cuts_pattern = str(cuts_dir / "cuts.%06d.jsonl.gz")
|
||||
target_rec_pattern = str(target_recordings_dir / "recording.%06d.tar")
|
||||
context_rec_pattern = str(context_recordings_dir / "recording.%06d.tar")
|
||||
|
||||
chunk_processed_count = 0
|
||||
chunk_audio_load_errors = 0 # Errors during audio loading phase for this chunk
|
||||
chunk_write_errors = 0 # Errors during write phase for this chunk
|
||||
|
||||
logging.debug(
|
||||
f"[Worker {worker_pid}, Chunk {chunk_idx}] Initializing writers with offset {chunk_idx} and processing {len(chunk_metadata)} pairs iteratively..."
|
||||
)
|
||||
try:
|
||||
# Specify shard_size with len(chunk_metadata) and shard_offset with chunk_idx, ensuring each chunk is written to a separate shard file.
|
||||
shard_size_for_worker = len(chunk_metadata)
|
||||
with (
|
||||
JsonlShardWriter(
|
||||
pattern=cuts_pattern, shard_size=shard_size_for_worker, shard_offset=chunk_idx
|
||||
) as cut_writer,
|
||||
AudioTarWriter(
|
||||
pattern=target_rec_pattern,
|
||||
shard_size=shard_size_for_worker,
|
||||
format=audio_format,
|
||||
shard_offset=chunk_idx,
|
||||
) as target_rec_writer,
|
||||
AudioTarWriter(
|
||||
pattern=context_rec_pattern,
|
||||
shard_size=shard_size_for_worker,
|
||||
format=audio_format,
|
||||
shard_offset=chunk_idx,
|
||||
) as context_rec_writer,
|
||||
):
|
||||
# Iterate directly over chunk_metadata
|
||||
for target_cut, context_cut in chunk_metadata:
|
||||
# 1. load target/context audio given the audio offset
|
||||
try:
|
||||
target_audio = target_cut.load_audio()
|
||||
context_audio = context_cut.load_audio()
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"[Worker {worker_pid}, Chunk {chunk_idx}] Error loading target/context audio for cut {target_cut}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
chunk_audio_load_errors += 1
|
||||
continue
|
||||
|
||||
# 2. Write target audio and context audio
|
||||
try:
|
||||
target_rec_writer.write(
|
||||
key=target_cut.id,
|
||||
value=target_audio,
|
||||
sampling_rate=target_cut.sampling_rate,
|
||||
manifest=to_shar_placeholder(
|
||||
target_cut.recording, target_cut
|
||||
), # update manifest.id with target_cut.id that has the audio offset and duration
|
||||
)
|
||||
context_rec_writer.write(
|
||||
key=target_cut.id, # use target cut id as key for context audio to ensure reference
|
||||
value=context_audio,
|
||||
sampling_rate=context_cut.sampling_rate,
|
||||
manifest=to_shar_placeholder(
|
||||
context_cut.recording, context_cut
|
||||
), # update manifest.id with context_cut.id that has the audio offset and duration
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"[Worker {worker_pid}, Chunk {chunk_idx}] Error writing target/context audio for target cut {target_cut}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
chunk_write_errors += 1
|
||||
continue
|
||||
|
||||
# 3. write cut metadata
|
||||
try:
|
||||
cut_writer.write(target_cut)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"[Worker {worker_pid}, Chunk {chunk_idx}] Error writing cut metadata for cut {target_cut}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
chunk_write_errors += 1
|
||||
continue
|
||||
|
||||
chunk_processed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"[Worker {worker_pid}, Chunk {chunk_idx}] CRITICAL error during writer initialization: {e}", exc_info=True
|
||||
)
|
||||
chunk_write_errors = len(chunk_metadata)
|
||||
chunk_processed_count = 0
|
||||
|
||||
# This part is only reached if the main try block completes without critical errors
|
||||
logging.debug(
|
||||
f"[Worker {worker_pid}, Chunk {chunk_idx}] Finished chunk. Processed: {chunk_processed_count}, Audio Load Errors: {chunk_audio_load_errors}, Write Errors: {chunk_write_errors}"
|
||||
)
|
||||
|
||||
return {
|
||||
"processed": chunk_processed_count,
|
||||
"initial_errors": initial_errors, # Errors from initial metadata processing
|
||||
"audio_load_errors": chunk_audio_load_errors,
|
||||
"write_errors": chunk_write_errors,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
description="Convert NeMo manifest to sharded Lhotse JSONL/TARs using parallel workers per chunk.",
|
||||
)
|
||||
parser.add_argument("--manifest-path", required=True, type=Path, help="Path to the input NeMo JSON manifest file.")
|
||||
parser.add_argument(
|
||||
"--audio-base-dir", required=True, type=Path, help="Base directory where audio files are located."
|
||||
)
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="Base directory to save the sharded outputs.")
|
||||
parser.add_argument(
|
||||
"--num-jobs",
|
||||
type=int,
|
||||
default=max(1, os.cpu_count() // 2),
|
||||
help="Number of parallel worker processes (each processing a whole chunk/shard).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--processing-chunk-size",
|
||||
type=int,
|
||||
default=4000,
|
||||
help="Number of manifest entries per chunk (effectively the items per output shard file).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio-format", type=str, default="flac", help="Audio format for TAR writers (e.g., flac, wav, opus)."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
type=str,
|
||||
default="INFO",
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
help="Set the logging level for the main process and workers.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--shuffle",
|
||||
action="store_true",
|
||||
help="Shuffle the manifest entries before processing.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--shuffle-seed",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Random seed for reproducible shuffling (only used if --shuffle is enabled).",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Configure logging based on argument
|
||||
log_level = getattr(logging, args.log_level.upper(), logging.INFO)
|
||||
log_format = '%(asctime)s - PID:%(process)d - %(levelname)s - %(message)s'
|
||||
logging.basicConfig(level=log_level, format=log_format)
|
||||
|
||||
# Ensure output directories exist
|
||||
cuts_dir = args.output_dir / "cuts"
|
||||
target_recordings_dir = args.output_dir / "target_audio"
|
||||
context_recordings_dir = args.output_dir / "context_audio"
|
||||
cuts_dir.mkdir(parents=True, exist_ok=True)
|
||||
target_recordings_dir.mkdir(parents=True, exist_ok=True)
|
||||
context_recordings_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Handle shuffling if requested
|
||||
if args.shuffle:
|
||||
logging.info(f"Shuffling manifest entries from: {args.manifest_path}")
|
||||
shuffled_manifest_path = shuffle_jsonl_file(args.manifest_path, seed=args.shuffle_seed)
|
||||
manifest_iterable = load_jsonl(shuffled_manifest_path)
|
||||
logging.info(f"Using shuffled manifest for processing: {shuffled_manifest_path}")
|
||||
else:
|
||||
logging.info(f"Reading NeMo manifest lazily from: {args.manifest_path}")
|
||||
manifest_iterable = load_jsonl(args.manifest_path)
|
||||
|
||||
logging.info(
|
||||
f"Processing manifest in chunks of {args.processing_chunk_size} using {args.num_jobs} parallel workers..."
|
||||
)
|
||||
|
||||
total_processed_count = 0
|
||||
total_initial_errors = 0
|
||||
total_audio_load_errors = 0
|
||||
total_write_errors = 0
|
||||
num_chunks = 0
|
||||
|
||||
worker_func = partial(
|
||||
process_and_write_chunk,
|
||||
audio_base_dir=args.audio_base_dir,
|
||||
output_dir=args.output_dir,
|
||||
audio_format=args.audio_format,
|
||||
)
|
||||
|
||||
with ProcessPoolExecutor(max_workers=args.num_jobs) as executor:
|
||||
# Enumerate chunks to pass index to worker. Each index is the same as the shard_offset.
|
||||
chunk_iterator = enumerate(chunked_iterator(manifest_iterable, args.processing_chunk_size))
|
||||
futures = {
|
||||
executor.submit(worker_func, chunk_with_idx): chunk_with_idx[0] for chunk_with_idx in chunk_iterator
|
||||
}
|
||||
num_chunks = len(futures)
|
||||
|
||||
logging.info(f"Submitted {num_chunks} chunks to workers.")
|
||||
|
||||
for future in tqdm(as_completed(futures), total=num_chunks, desc="Processing Chunks"):
|
||||
chunk_idx = futures[future]
|
||||
try:
|
||||
result = future.result()
|
||||
total_processed_count += result["processed"]
|
||||
total_initial_errors += result["initial_errors"]
|
||||
total_audio_load_errors += result["audio_load_errors"]
|
||||
total_write_errors += result["write_errors"]
|
||||
logging.debug(f"Chunk {chunk_idx} finished with result: {result}")
|
||||
except Exception as e:
|
||||
logging.error(f"Chunk {chunk_idx} failed with exception: {e}", exc_info=True)
|
||||
# Increment error count based on chunk size. Difficult to know precisely. Assume all failed.
|
||||
total_initial_errors += args.processing_chunk_size
|
||||
|
||||
logging.info("=" * 30 + " Processing Summary " + "=" * 30)
|
||||
logging.info(f"Total chunks processed: {num_chunks}")
|
||||
logging.info(f"Successfully processed and wrote data for approximately {total_processed_count} entries.")
|
||||
total_errors = total_initial_errors + total_audio_load_errors + total_write_errors
|
||||
if total_errors > 0:
|
||||
logging.warning(f"Encountered errors/skips in {total_errors} potential entries:")
|
||||
logging.warning(f" - Initial processing errors/skips: {total_initial_errors}")
|
||||
logging.warning(f" - Audio loading errors/skips (affecting writes): {total_audio_load_errors}")
|
||||
logging.warning(f" - Writing errors: {total_write_errors}")
|
||||
logging.warning("Check logs above (use DEBUG level for more details) for specific entry issues.")
|
||||
else:
|
||||
logging.info("No significant errors reported.")
|
||||
logging.info("Manifest creation finished.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,349 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
This script creates chosen-rejected pairs for DPO/RPO.
|
||||
We match the manifest records with the generated audio files and metrics.
|
||||
The script then creates a new manifest with chosen-rejected pairs.
|
||||
which is used for training and validation manifest for DPO training.
|
||||
|
||||
Arguments:
|
||||
--input_manifest: Path to the input JSON manifest file containing text/context records.
|
||||
--generated_audio_dir: Directory containing generated audio files and associated metadata.
|
||||
--group_size: Number of records per group used for ranking.
|
||||
--cer_threshold: CER threshold for chosen records. Only records with CER <= threshold are retained.
|
||||
--val_size: Number of validation samples to retain.
|
||||
"""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input_manifest", type=str)
|
||||
parser.add_argument(
|
||||
"--generated_audio_dir",
|
||||
type=str,
|
||||
)
|
||||
parser.add_argument("--group_size", type=int, default=4)
|
||||
parser.add_argument("--cer_threshold", type=float, default=0.02)
|
||||
parser.add_argument(
|
||||
"--min_length_threshold",
|
||||
type=float,
|
||||
default=1.5,
|
||||
help="Minimum length permitted. Set this shorter to allow very short sentences (which can be useful for DPO tuning.",
|
||||
)
|
||||
parser.add_argument("--val_size", type=int, default=64)
|
||||
args = parser.parse_args()
|
||||
|
||||
records = read_manifest(args.input_manifest)
|
||||
audio_files, codec_files, metric_files = find_audio_files(args.generated_audio_dir)
|
||||
assert len(records) <= len(
|
||||
audio_files
|
||||
), "Mismatch between number of records and number of generated audio files {} vs {}".format(
|
||||
len(records), len(audio_files)
|
||||
)
|
||||
|
||||
for idx, record in tqdm(enumerate(records)):
|
||||
if idx % 100 == 0:
|
||||
print("At idx: ", idx, len(records))
|
||||
record['audio_filepath'] = audio_files[idx]
|
||||
record['target_audio_codes_path'] = codec_files[idx]
|
||||
with open(metric_files[idx], 'r') as f:
|
||||
metrics = json.load(f)
|
||||
record['duration'] = metrics['duration']
|
||||
record['cer_gts'] = metrics['cer_gt']
|
||||
record['wer_gts'] = metrics['wer_gt']
|
||||
record['pred_context_similarity'] = metrics['spk_similarity']
|
||||
record['pred_transcript'] = metrics['pred_transcript']
|
||||
record['gt_transcript'] = metrics['gt_transcript']
|
||||
|
||||
out_manifest_dir = args.generated_audio_dir.replace("/audios", "/manifests")
|
||||
if not os.path.exists(out_manifest_dir):
|
||||
os.makedirs(out_manifest_dir)
|
||||
|
||||
out_manifest = os.path.join(out_manifest_dir, "manifest_with_metrics.json")
|
||||
write_manifest(out_manifest, records)
|
||||
|
||||
group_size = args.group_size
|
||||
val_size = args.val_size
|
||||
|
||||
for num_chosen_per_group in [1, 2]:
|
||||
all_best_records, all_worst_records = create_chosen_rejected_records(records, group_size, num_chosen_per_group)
|
||||
print("Len all_best_records: ", len(all_best_records))
|
||||
print("Len all_worst_records: ", len(all_worst_records))
|
||||
best_records, worst_records = filter_best_and_worst_records(
|
||||
all_best_records, all_worst_records, args.cer_threshold, args.min_length_threshold
|
||||
)
|
||||
print("Len filtered best_records: ", len(best_records))
|
||||
print("Len filtered worst_records: ", len(worst_records))
|
||||
worst_records = normalize_rejected_rewards(worst_records)
|
||||
paired_records = [
|
||||
(best_record, worst_record) for best_record, worst_record in zip(best_records, worst_records)
|
||||
]
|
||||
random.shuffle(paired_records)
|
||||
|
||||
final_records = []
|
||||
for best_record, worst_record in paired_records:
|
||||
assert best_record['reward'] == 1
|
||||
assert worst_record['reward'] < 1
|
||||
final_records.append(best_record)
|
||||
final_records.append(worst_record)
|
||||
|
||||
final_records_val = final_records[:val_size]
|
||||
final_records_train = final_records[val_size:]
|
||||
|
||||
train_manifest = os.path.join(
|
||||
out_manifest_dir, "dpo_train_manifest_numchosen_per_group_{}.json".format(num_chosen_per_group)
|
||||
)
|
||||
val_manifest = os.path.join(
|
||||
out_manifest_dir, "dpo_val_manifest_numchosen_per_group_{}.json".format(num_chosen_per_group)
|
||||
)
|
||||
|
||||
write_manifest(train_manifest, final_records_train)
|
||||
write_manifest(val_manifest, final_records_val)
|
||||
|
||||
|
||||
def read_manifest(manifest_path):
|
||||
with open(manifest_path, 'r') as f:
|
||||
lines = f.readlines()
|
||||
records = []
|
||||
for line in lines:
|
||||
records.append(json.loads(line.strip()))
|
||||
return records
|
||||
|
||||
|
||||
def write_manifest(fp, records):
|
||||
with open(fp, "w") as f:
|
||||
for record in records:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
print("Wrote {} records to: {}".format(len(records), fp))
|
||||
|
||||
|
||||
def find_audio_files(directory):
|
||||
audio_files = []
|
||||
unique_ranks = {}
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if file.endswith(".wav"):
|
||||
rank_num = int(file.split("Rank")[1].split("_")[0])
|
||||
unique_ranks[rank_num] = True
|
||||
audio_num = int(file.split(".wav")[0].split("_")[-1])
|
||||
audio_files.append((rank_num, audio_num, os.path.join(root, file)))
|
||||
ranked_audio_files = []
|
||||
for af in audio_files:
|
||||
rank, num, path = af
|
||||
audio_num = num * len(unique_ranks) + rank
|
||||
ranked_audio_files.append((audio_num, path))
|
||||
ranked_audio_files = sorted(ranked_audio_files, key=lambda x: x[0])
|
||||
ranked_audio_files = [x[1] for x in ranked_audio_files]
|
||||
ranked_codec_files = [f.replace(".wav", "_codes.pt") for f in ranked_audio_files]
|
||||
metric_files = [f.replace(".wav", "_metrics.json") for f in ranked_audio_files]
|
||||
|
||||
return ranked_audio_files, ranked_codec_files, metric_files
|
||||
|
||||
|
||||
def pareto_rank(items):
|
||||
"""
|
||||
Given a list of (cer, ssim, item_idx), return the list of items
|
||||
sorted by their Pareto rank (rank 1 is best). Items in the same
|
||||
rank are sorted by ascending cer.
|
||||
|
||||
:param items: List of tuples (cer, ssim, item_idx).
|
||||
:return: A list of tuples (rank, cer, ssim, item_idx), sorted first by rank,
|
||||
then by ascending cer within the same rank.
|
||||
"""
|
||||
|
||||
# A helper function to check if item A is dominated by item B
|
||||
# A: (cerA, ssimA), B: (cerB, ssimB)
|
||||
def is_dominated(A, B):
|
||||
assert len(A) == 2
|
||||
assert len(B) == 2
|
||||
return (B[0] <= A[0]) and (B[1] >= A[1]) and (B != A)
|
||||
# Equivalently, check at least one strict inequality:
|
||||
# (B[0] < A[0]) or (B[1] > A[1])
|
||||
# can be factored into the condition:
|
||||
# (B[0] <= A[0]) and (B[1] >= A[1]) and (B != A)
|
||||
|
||||
# Make a working copy so we can remove items
|
||||
remaining = items[:]
|
||||
|
||||
ranked_items = [] # Will hold tuples of (rank, cer, ssim, item_idx)
|
||||
current_rank = 1
|
||||
|
||||
while remaining:
|
||||
# Find all non-dominated items in the current set 'remaining'
|
||||
non_dominated = []
|
||||
for i in range(len(remaining)):
|
||||
dominated = False
|
||||
for j in range(len(remaining)):
|
||||
if i != j:
|
||||
if is_dominated(remaining[i][:2], remaining[j][:2]):
|
||||
dominated = True
|
||||
break
|
||||
if not dominated:
|
||||
non_dominated.append(remaining[i])
|
||||
|
||||
# Assign current_rank to all non-dominated items
|
||||
# and remove them from remaining
|
||||
for nd in non_dominated:
|
||||
ranked_items.append((current_rank, nd[0], nd[1], nd[2]))
|
||||
remaining.remove(nd)
|
||||
|
||||
current_rank += 1
|
||||
|
||||
# Now sort the ranked items by (rank asc, cer asc, ssim desc)
|
||||
ranked_items.sort(key=lambda x: (x[0], x[1], -x[2]))
|
||||
|
||||
return ranked_items
|
||||
|
||||
|
||||
def standard_normal_cdf(z):
|
||||
"""
|
||||
Compute the standard normal cumulative distribution function (CDF) for a given z-score.
|
||||
"""
|
||||
return 0.5 * (1 + math.erf(z / math.sqrt(2)))
|
||||
|
||||
|
||||
def normalize_rejected_rewards(worst_records):
|
||||
cer_deltas = [record['cer_delta'] for record in worst_records]
|
||||
sim_deltas = [record['sim_delta'] for record in worst_records]
|
||||
cer_mean = sum(cer_deltas) / len(cer_deltas)
|
||||
cer_std = math.sqrt(sum([(d - cer_mean) ** 2 for d in cer_deltas]) / len(cer_deltas))
|
||||
sim_mean = sum(sim_deltas) / len(sim_deltas)
|
||||
sim_std = math.sqrt(sum([(d - sim_mean) ** 2 for d in sim_deltas]) / len(sim_deltas))
|
||||
|
||||
for record in worst_records:
|
||||
cer_z_score = (record['cer_delta'] - cer_mean) / cer_std
|
||||
sim_z_score = (record['sim_delta'] - sim_mean) / sim_std
|
||||
record['reward'] = 1.0 - (standard_normal_cdf(cer_z_score) + standard_normal_cdf(sim_z_score)) # Range -1 to 1
|
||||
|
||||
return worst_records
|
||||
|
||||
|
||||
def create_chosen_rejected_records(records_orig, group_size=6, num_chosen_per_group=1):
|
||||
records = copy.deepcopy(records_orig)
|
||||
assert len(records) % group_size == 0
|
||||
num_groups = len(records) // group_size
|
||||
best_records = []
|
||||
worst_records = []
|
||||
num_skipped = 0
|
||||
|
||||
if num_chosen_per_group == 1:
|
||||
chosen_group_indices = [0]
|
||||
rejected_group_indices = [group_size - 1]
|
||||
elif num_chosen_per_group == 2:
|
||||
chosen_group_indices = [0, 1]
|
||||
rejected_group_indices = [group_size - 1, group_size - 2]
|
||||
else:
|
||||
raise ValueError("num_chosen_per_group must be 1 or 2")
|
||||
|
||||
for gidx in range(num_groups):
|
||||
gsi = gidx * group_size
|
||||
gei = (gidx + 1) * group_size
|
||||
group = records[gsi:gei]
|
||||
|
||||
cer_sim_indices = []
|
||||
skip_group = False
|
||||
for sidx, record in enumerate(group):
|
||||
if record['pred_transcript'] == "<INVALID>":
|
||||
print(f"Skipping group starting at index {gsi} due to invalid entries.")
|
||||
num_skipped += len(group)
|
||||
skip_group = True
|
||||
break
|
||||
cer_sim_indices.append((record['cer_gts'], record['pred_context_similarity'], sidx))
|
||||
if skip_group:
|
||||
continue
|
||||
cer_sim_indices_orig = copy.deepcopy(cer_sim_indices)
|
||||
cer_sim_indices = pareto_rank(cer_sim_indices)
|
||||
|
||||
for cgi in chosen_group_indices:
|
||||
for rji in rejected_group_indices:
|
||||
best_record = group[cer_sim_indices[cgi][3]]
|
||||
worst_record = group[cer_sim_indices[rji][3]]
|
||||
best_record['reward'] = 1
|
||||
reward_delta = (worst_record['cer_gts'] - best_record['cer_gts']) + (
|
||||
best_record['pred_context_similarity'] - worst_record['pred_context_similarity']
|
||||
)
|
||||
if (
|
||||
reward_delta <= 0
|
||||
or worst_record['cer_gts'] < best_record['cer_gts']
|
||||
or worst_record['pred_context_similarity'] > best_record['pred_context_similarity']
|
||||
):
|
||||
print(
|
||||
"Warning reward_delta is not positive",
|
||||
reward_delta,
|
||||
best_record['cer_gts'],
|
||||
worst_record['cer_gts'],
|
||||
best_record['pred_context_similarity'],
|
||||
worst_record['pred_context_similarity'],
|
||||
)
|
||||
print(cer_sim_indices_orig)
|
||||
print(cer_sim_indices)
|
||||
else:
|
||||
# Never add pairs in which rejected has better CER than chosen or better context similarity
|
||||
reward_delta = max(0.001, reward_delta)
|
||||
worst_record['reward'] = 1.0 - reward_delta
|
||||
worst_record['cer_delta'] = worst_record['cer_gts'] - best_record['cer_gts']
|
||||
worst_record['sim_delta'] = (
|
||||
best_record['pred_context_similarity'] - worst_record['pred_context_similarity']
|
||||
)
|
||||
best_records.append(best_record)
|
||||
worst_records.append(worst_record)
|
||||
|
||||
print(f"Skipped {num_skipped} records due to invalid entries.")
|
||||
return best_records, worst_records
|
||||
|
||||
|
||||
def filter_best_and_worst_records(best_records, worst_records, cer_threshold=0.02, min_length_threshold=1.5):
|
||||
ridx = 0
|
||||
filtered_best_records = []
|
||||
filtered_worst_records = []
|
||||
best_cer_avg = 0.0
|
||||
worst_cer_avg = 0.0
|
||||
skipped_records = 0
|
||||
while ridx < len(best_records):
|
||||
# print(ridx, len(best_records))
|
||||
best_record = best_records[ridx]
|
||||
if best_record['cer_gts'] < cer_threshold:
|
||||
worst_record = worst_records[ridx]
|
||||
if (worst_record['duration'] > 19.0 or best_record['duration'] > 19.0) or (
|
||||
worst_record['duration'] < min_length_threshold or best_record['duration'] < min_length_threshold
|
||||
):
|
||||
skipped_records += 1
|
||||
ridx += 1
|
||||
continue
|
||||
assert best_record['cer_gts'] <= worst_record['cer_gts']
|
||||
if worst_record['cer_gts'] == best_record['cer_gts']:
|
||||
assert worst_record['pred_context_similarity'] <= best_record['pred_context_similarity']
|
||||
|
||||
filtered_best_records.append(best_record)
|
||||
filtered_worst_records.append(worst_record)
|
||||
best_cer_avg += best_record['cer_gts']
|
||||
worst_cer_avg += worst_record['cer_gts']
|
||||
ridx += 1
|
||||
|
||||
best_cer_avg /= len(filtered_best_records)
|
||||
worst_cer_avg /= len(filtered_worst_records)
|
||||
print(f"Best CER avg: {best_cer_avg}, Worst CER avg: {worst_cer_avg}")
|
||||
return filtered_best_records, filtered_worst_records
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,180 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
|
||||
|
||||
def write_manifest(fp, records):
|
||||
"""
|
||||
Writes a list of records to a JSON file, where each record is written as a new line.
|
||||
|
||||
Args:
|
||||
fp (str): File path where the records should be written.
|
||||
records (list): List of records (dictionaries) to write.
|
||||
"""
|
||||
with open(fp, "w") as f:
|
||||
for record in records:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
print("Wrote {} records to: {}".format(len(records), fp))
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Processes text and audio context data to create text-context pairs.
|
||||
The resulting dataset is saved as a JSON manifest file.
|
||||
|
||||
Example usage:
|
||||
python scripts/magpietts/dpo/create_text_contextpairs.py \
|
||||
--challenging_texts /Data/DPOPairsInputDatav2/challenging_with_short.txt \
|
||||
--regular_texts_for_audiocontext /Data/DPOPairsInputDatav2/regular_texts_for_audiocontext.txt \
|
||||
--regular_texts_for_textcontext /Data/DPOPairsInputDatav2/regular_texts_for_textcontext.txt \
|
||||
--audio_contexts /Data/DPOPairsInputDatav2/audio_context_list.json \
|
||||
--text_contexts /Data/DPOPairsInputDatav2/text_context_list_with_audio.txt \
|
||||
--output_manifest /Data/DPOPairsInputDatav2/grpo_train_with_short.json \
|
||||
--n_audio_contexts_per_challenging_text 2 \
|
||||
--n_text_contexts_per_challenging_text 2 \
|
||||
--n_audio_contexts_per_regular_text 1 \
|
||||
--n_text_contexts_per_regular_text 1 \
|
||||
--nsamples_perpair 1 ;
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description='Create text-context pairs for DPO')
|
||||
parser.add_argument("--challenging_texts", type=str, help="Text file containing challenging texts")
|
||||
parser.add_argument("--regular_texts_for_audiocontext", type=str, help="Text file containing regular texts")
|
||||
parser.add_argument("--regular_texts_for_textcontext", type=str, help="Text file containing regular texts")
|
||||
parser.add_argument(
|
||||
"--audio_contexts", type=str, help="Manifest containing audio contexts"
|
||||
) # This manifest should contain 'context_audio_filepath', 'context_audio_duration' and (optionally) 'context_audio_codes_path'
|
||||
parser.add_argument("--text_contexts", type=str, help="Text file containing text contexts")
|
||||
parser.add_argument("--n_audio_contexts_per_challenging_text", type=int, default=10)
|
||||
parser.add_argument("--n_audio_contexts_per_regular_text", type=int, default=1)
|
||||
parser.add_argument("--n_text_contexts_per_challenging_text", type=int, default=3)
|
||||
parser.add_argument("--n_text_contexts_per_regular_text", type=int, default=1)
|
||||
parser.add_argument("--nsamples_perpair", type=int, default=6)
|
||||
parser.add_argument("--output_manifest", type=str)
|
||||
args = parser.parse_args()
|
||||
|
||||
with open(args.challenging_texts, 'r') as f:
|
||||
challenging_texts = f.readlines()
|
||||
challenging_texts = [text.strip() for text in challenging_texts if text.strip() != '']
|
||||
|
||||
with open(args.regular_texts_for_audiocontext, 'r') as f:
|
||||
regular_texts_for_audiocontext = f.readlines()
|
||||
regular_texts_for_audiocontext = [
|
||||
text.strip() for text in regular_texts_for_audiocontext if text.strip() != ''
|
||||
]
|
||||
|
||||
with open(args.regular_texts_for_textcontext, 'r') as f:
|
||||
regular_texts_for_textcontext = f.readlines()
|
||||
regular_texts_for_textcontext = [text.strip() for text in regular_texts_for_textcontext if text.strip() != '']
|
||||
|
||||
with open(args.audio_contexts, 'r') as f:
|
||||
audio_contexts = f.readlines()
|
||||
audio_contexts = [json.loads(context.strip()) for context in audio_contexts if context.strip() != '']
|
||||
|
||||
with open(args.text_contexts, 'r') as f:
|
||||
text_contexts = f.readlines()
|
||||
text_contexts = [text for text in text_contexts if text.strip() != '']
|
||||
|
||||
all_records = []
|
||||
for challenging_text in challenging_texts:
|
||||
for _ in range(args.n_audio_contexts_per_challenging_text):
|
||||
audio_context = random.choice(audio_contexts)
|
||||
record = create_audio_context_record(challenging_text, audio_context, 'challenging')
|
||||
all_records.append(record)
|
||||
|
||||
for _ in range(args.n_text_contexts_per_challenging_text):
|
||||
text_context = random.choice(text_contexts)
|
||||
record = create_text_context_record(challenging_text, text_context, 'challenging')
|
||||
all_records.append(record)
|
||||
|
||||
for regular_text in regular_texts_for_audiocontext:
|
||||
for _ in range(args.n_audio_contexts_per_regular_text):
|
||||
audio_context = random.choice(audio_contexts)
|
||||
record = create_audio_context_record(regular_text, audio_context, 'regular')
|
||||
all_records.append(record)
|
||||
|
||||
for regular_text in regular_texts_for_textcontext:
|
||||
for _ in range(args.n_text_contexts_per_regular_text):
|
||||
text_context = random.choice(text_contexts)
|
||||
record = create_text_context_record(regular_text, text_context, 'regular')
|
||||
all_records.append(record)
|
||||
|
||||
random.shuffle(all_records)
|
||||
repeated_records = []
|
||||
for record in all_records:
|
||||
for _ in range(args.nsamples_perpair):
|
||||
repeated_records.append(record)
|
||||
|
||||
write_manifest(args.output_manifest, repeated_records)
|
||||
write_manifest(
|
||||
args.output_manifest.replace(".json", "_tinysubset.json"), repeated_records[: 100 * args.nsamples_perpair]
|
||||
)
|
||||
|
||||
|
||||
def create_audio_context_record(text, audio_context, record_type):
|
||||
"""
|
||||
Creates a record for a text-context pair with audio context.
|
||||
|
||||
Args:
|
||||
text (str): The main text content.
|
||||
audio_context (dict): Dictionary containing audio context information.
|
||||
record_type (str): Type of record ('challenging' or 'regular').
|
||||
|
||||
Returns:
|
||||
dict: A dictionary representing the audio context record.
|
||||
"""
|
||||
record = {
|
||||
'text': text,
|
||||
'duration': 6.0, # Does not matter, avoids filtering out in DPO,
|
||||
'audio_filepath': audio_context['context_audio_filepath'],
|
||||
'context_audio_filepath': audio_context['context_audio_filepath'],
|
||||
'context_audio_duration': audio_context['context_audio_duration'],
|
||||
'record_type': record_type, # challenging or regular
|
||||
}
|
||||
if 'context_audio_codes_path' in audio_context:
|
||||
record['context_audio_codes_path'] = audio_context['context_audio_codes_path']
|
||||
record['target_audio_codes_path'] = audio_context['context_audio_codes_path']
|
||||
|
||||
return record
|
||||
|
||||
|
||||
def create_text_context_record(text, text_context, record_type):
|
||||
"""
|
||||
Creates a record for a text-context pair with text context.
|
||||
|
||||
Args:
|
||||
text (str): The main text content.
|
||||
text_context (str): The associated text context.
|
||||
record_type (str): Type of record ('challenging' or 'regular').
|
||||
|
||||
Returns:
|
||||
dict: A dictionary representing the text context record.
|
||||
"""
|
||||
if text_context.endswith("\n"):
|
||||
text_context = text_context[:-1]
|
||||
record = {
|
||||
'text': text,
|
||||
'duration': 6.0, # Does not matter, avoids filtering out in DPO,
|
||||
'audio_filepath': text_context.split(",")[1],
|
||||
'context_text': text_context.split(",")[0],
|
||||
'record_type': record_type, # challenging or regular
|
||||
}
|
||||
if text_context.split(",")[-1].endswith(".pt"):
|
||||
record['target_audio_codes_path'] = text_context.split(",")[-1]
|
||||
return record
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,905 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script extends the Lhotse shards with audio codec codes.
|
||||
|
||||
Example of input shards:
|
||||
$ tree ${CUTS_DIR}
|
||||
${CUTS_DIR}/
|
||||
cuts.000000.jsonl.gz
|
||||
cuts.000001.jsonl.gz
|
||||
...
|
||||
|
||||
$ tree ${TARGET_AUDIO_DIR}
|
||||
${TARGET_AUDIO_DIR}/
|
||||
recording.000000.tar
|
||||
recording.000001.tar
|
||||
...
|
||||
|
||||
$ tree ${CONTEXT_AUDIO_DIR}
|
||||
${CONTEXT_AUDIO_DIR}/
|
||||
recording.000000.tar
|
||||
recording.000001.tar
|
||||
...
|
||||
|
||||
Example usage:
|
||||
export WANDB_API_KEY=${WANDB}
|
||||
python -u ${CODE_DIR}/scripts/magpietts/extend_lhotse_shards_with_audio_codes.py \
|
||||
--cuts-dir ${CUTS_DIR} \
|
||||
--target-audio-dir ${TARGET_AUDIO_DIR} \
|
||||
--context-audio-dir ${CONTEXT_AUDIO_DIR} \
|
||||
--output-dir ${RESULTS} \
|
||||
--codec-model-name ${CODEC_MODEL_NAME} \
|
||||
--codec-model-path ${CODEC_MODEL_PATH} \
|
||||
--codec-frame-rate ${CODEC_FRAME_RATE} \
|
||||
--devices ${DEVICES} \
|
||||
--num-nodes ${NUM_NODES} \
|
||||
--batch-size ${BATCH_SIZE} \
|
||||
--buffer-size ${BUFFER_SIZE} \
|
||||
--wandb-entity ${WANDB_ENTITY} \
|
||||
--wandb-project ${WANDB_PROJECT} \
|
||||
--wandb-name ${WANDB_NAME} \
|
||||
--log-level "DEBUG" \
|
||||
2>&1 | tee ${LOG}/${WANDB_NAME}.stdout
|
||||
|
||||
Expected output:
|
||||
$ tree ${RESULTS}
|
||||
${RESULTS}/
|
||||
21fpsCausalDecoder/
|
||||
target_codes/
|
||||
codes.000000.tar
|
||||
codes.000001.tar
|
||||
...
|
||||
context_codes/
|
||||
codes.000000.tar
|
||||
codes.000001.tar
|
||||
...
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import lightning.pytorch as pl
|
||||
import torch
|
||||
import wandb
|
||||
from lhotse import CutSet
|
||||
from lhotse.array import Array, TemporalArray
|
||||
from lhotse.dataset import IterableDatasetWrapper, SimpleCutSampler
|
||||
from lhotse.shar.writers.array import ArrayTarWriter
|
||||
from lightning.pytorch import Trainer
|
||||
from lightning.pytorch.callbacks import BasePredictionWriter
|
||||
from lightning.pytorch.loggers import WandbLogger
|
||||
from lightning.pytorch.strategies import DDPStrategy
|
||||
from torch.utils.data import DataLoader, Dataset
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.tts.models import AudioCodecModel
|
||||
|
||||
|
||||
def compute_effective_audio_length(original_audio_tensor: torch.Tensor, samples_per_frame: int) -> int:
|
||||
"""Computes the effective length of an audio tensor, padded to be a multiple of samples_per_frame."""
|
||||
original_len = original_audio_tensor.shape[0]
|
||||
effective_len = original_len
|
||||
if samples_per_frame > 0:
|
||||
effective_len = ((original_len + samples_per_frame - 1) // samples_per_frame) * samples_per_frame
|
||||
return effective_len
|
||||
|
||||
|
||||
def collate_audio_vectors(
|
||||
audio_list: List[torch.Tensor], audio_lens_list: List[int], padding_value: Union[float, int]
|
||||
) -> torch.Tensor:
|
||||
"""
|
||||
Collate a list of audio vectors into a single tensor, handling padding for variable lengths.
|
||||
Returns a padded tensor.
|
||||
"""
|
||||
assert all(len(t.shape) == 1 for t in audio_list), "Expected only 1-D input tensors."
|
||||
assert len(audio_list) == len(audio_lens_list), "Expected the same number of audio vectors and lengths."
|
||||
|
||||
# Create a padded tensor with the maximum audio length from audio_lens_list, where its max length could be longer than
|
||||
# max length of `audio_list``. For example, `audio_lens_list` could be a multiple of the codec model samples per frame.
|
||||
result = audio_list[0].new_ones(len(audio_lens_list), max(audio_lens_list)) * padding_value
|
||||
for i, t in enumerate(audio_list):
|
||||
result[i, : t.shape[0]] = t
|
||||
return result
|
||||
|
||||
|
||||
class AudioPairLhotseDataset(Dataset):
|
||||
"""
|
||||
A Lhotse Dataset that processes a batch of MonoCuts (received as a CutSet)
|
||||
containing target and context audio.
|
||||
Designed to be used with a Lhotse sampler yielding CutSet batches.
|
||||
Handles loading audio and collating the batch within __getitem__.
|
||||
"""
|
||||
|
||||
def __init__(self, target_sample_rate: int, codec_model_samples_per_frame: int):
|
||||
self.target_sample_rate = target_sample_rate
|
||||
self.codec_model_samples_per_frame = codec_model_samples_per_frame
|
||||
|
||||
def __getitem__(self, cuts: CutSet) -> Optional[Dict[str, Any]]:
|
||||
original_target_audios_list = []
|
||||
effective_target_lengths_list = []
|
||||
original_context_audios_list = []
|
||||
effective_context_lengths_list = []
|
||||
target_cut_ids_list = []
|
||||
shard_indices_list = []
|
||||
|
||||
for cut in cuts:
|
||||
if not cut.has_custom("shard_origin"):
|
||||
err_msg = f"Cut {cut} is missing required key 'shard_origin'."
|
||||
logging.error(err_msg)
|
||||
raise ValueError(err_msg)
|
||||
if not cut.has_custom("context_recording"):
|
||||
err_msg = f"Cut {cut} is missing required key 'context_recording'."
|
||||
logging.error(err_msg)
|
||||
raise ValueError(err_msg)
|
||||
|
||||
# Parse shard index from the custom field, handling potential errors
|
||||
origin_path = cut.custom["shard_origin"]
|
||||
match = re.search(r"cuts\.(\d+)\.jsonl\.gz$", origin_path)
|
||||
if match is None:
|
||||
raise ValueError(f"Could not parse shard index from shard_origin: {origin_path}")
|
||||
shard_idx_origin = int(match.group(1))
|
||||
|
||||
# audio shape: (num_channels (1), num_samples) -> (num_samples)
|
||||
# resample to target sample rate
|
||||
target_audio = torch.from_numpy(cut.recording.resample(self.target_sample_rate).load_audio().squeeze(0))
|
||||
context_audio = torch.from_numpy(
|
||||
cut.context_recording.resample(self.target_sample_rate).load_audio().squeeze(0)
|
||||
)
|
||||
original_target_audios_list.append(target_audio)
|
||||
original_context_audios_list.append(context_audio)
|
||||
|
||||
eff_target_len = compute_effective_audio_length(target_audio, self.codec_model_samples_per_frame)
|
||||
effective_target_lengths_list.append(eff_target_len)
|
||||
|
||||
eff_context_len = compute_effective_audio_length(context_audio, self.codec_model_samples_per_frame)
|
||||
effective_context_lengths_list.append(eff_context_len)
|
||||
|
||||
target_cut_ids_list.append(cut.id)
|
||||
shard_indices_list.append(shard_idx_origin)
|
||||
|
||||
# Ensure lists are not empty before calling collate_audio_vectors.
|
||||
if not original_target_audios_list:
|
||||
err_msg = "AudioPairLhotseDataset.__getitem__ processed an empty CutSet or failed to load any audio data, resulting in an empty audio list."
|
||||
logging.error(err_msg)
|
||||
raise ValueError(err_msg)
|
||||
|
||||
target_audio_padded_batch = collate_audio_vectors(
|
||||
original_target_audios_list, effective_target_lengths_list, padding_value=0.0
|
||||
)
|
||||
context_audio_padded_batch = collate_audio_vectors(
|
||||
original_context_audios_list, effective_context_lengths_list, padding_value=0.0
|
||||
)
|
||||
|
||||
# TODO: is it really necessary to convert lengths to torch.int64? currently applying torch.int32.
|
||||
target_audio_lens_collated = torch.IntTensor(effective_target_lengths_list)
|
||||
context_audio_lens_collated = torch.IntTensor(effective_context_lengths_list)
|
||||
|
||||
return {
|
||||
"target_audios": target_audio_padded_batch,
|
||||
"target_audio_lens": target_audio_lens_collated,
|
||||
"context_audios": context_audio_padded_batch,
|
||||
"context_audio_lens": context_audio_lens_collated,
|
||||
"target_cut_id": target_cut_ids_list,
|
||||
"shard_idx_origin": shard_indices_list,
|
||||
}
|
||||
|
||||
|
||||
class CodecExtractor(pl.LightningModule):
|
||||
"""
|
||||
LightningModule to extract codec codes. Manages DataLoader creation and
|
||||
distribution via predict_dataloader hook.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_path: str,
|
||||
cuts_dir: str,
|
||||
target_audio_dir: str,
|
||||
context_audio_dir: str,
|
||||
batch_size: int,
|
||||
):
|
||||
super().__init__()
|
||||
self.model_path = model_path
|
||||
self.cuts_dir = Path(cuts_dir)
|
||||
self.target_audio_dir = Path(target_audio_dir)
|
||||
self.context_audio_dir = Path(context_audio_dir)
|
||||
self.batch_size = batch_size
|
||||
|
||||
logging.info(f"Initializing `AudioPairLhotseDataset` with model path: {self.model_path}")
|
||||
# load the model. mapping to cpu is to avoid GPU mem spikes when initializing the model
|
||||
self.codec_model = AudioCodecModel.restore_from(restore_path=self.model_path, map_location='cpu', strict=False)
|
||||
self.codec_model.eval()
|
||||
logging.info("Codec model loaded.")
|
||||
|
||||
# Placeholder for the rank-specific list of dataloaders
|
||||
self._rank_dataloaders: Optional[List[DataLoader]] = None
|
||||
|
||||
def predict_dataloader(self) -> List[DataLoader]:
|
||||
"""
|
||||
Creates and returns the list of DataLoaders assigned to the current rank.
|
||||
Caches the result to avoid redundant creation.
|
||||
|
||||
This function is called by the Trainer to get the dataloaders for the current rank. This happens after
|
||||
intializing `model.predict()` but before any actual prediction steps (ie. calls to `model.predict_step()`) are executed.
|
||||
"""
|
||||
# Return cached dataloaders if already created for this rank
|
||||
if self._rank_dataloaders is not None:
|
||||
return self._rank_dataloaders
|
||||
|
||||
# Determine rank and world size
|
||||
try:
|
||||
# Prefer trainer attributes if available
|
||||
current_global_rank = self.global_rank
|
||||
world_size = self.trainer.world_size
|
||||
except AttributeError:
|
||||
# Fallback to torch.distributed if trainer attributes aren't set yet
|
||||
current_global_rank = torch.distributed.get_rank() if torch.distributed.is_initialized() else 0
|
||||
world_size = torch.distributed.get_world_size() if torch.distributed.is_initialized() else 1
|
||||
|
||||
logging.info(f"[Rank {current_global_rank}/{world_size}] Creating assigned subset of dataloaders...")
|
||||
|
||||
# Find all shard files globally
|
||||
cuts_shard_pattern = str(self.cuts_dir / "cuts.*.jsonl.gz")
|
||||
all_cuts_shard_paths = sorted(glob.glob(cuts_shard_pattern))
|
||||
|
||||
if not all_cuts_shard_paths:
|
||||
msg = f"[Rank {current_global_rank}/{world_size}] No input cut shards found matching pattern: {cuts_shard_pattern}. Cannot proceed."
|
||||
logging.error(msg)
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
num_total_shards = len(all_cuts_shard_paths)
|
||||
|
||||
# Verify shard indices are contiguous and start from 0 based on filenames (globally)
|
||||
first_idx_str = re.search(r"cuts\.(\d+)\.jsonl\.gz$", all_cuts_shard_paths[0]).group(1)
|
||||
last_idx_str = re.search(r"cuts\.(\d+)\.jsonl\.gz$", all_cuts_shard_paths[-1]).group(1)
|
||||
first_idx = int(first_idx_str)
|
||||
last_idx = int(last_idx_str)
|
||||
expected_last_idx = num_total_shards - 1
|
||||
if first_idx != 0:
|
||||
raise ValueError(f"Expected first shard index to be 0, but found {first_idx} in {all_cuts_shard_paths[0]}")
|
||||
if last_idx != expected_last_idx:
|
||||
raise ValueError(
|
||||
f"Expected last shard index to be {expected_last_idx}, but found {last_idx} in {all_cuts_shard_paths[-1]}"
|
||||
)
|
||||
logging.info(
|
||||
f"[Rank {current_global_rank}/{world_size}] Verified {num_total_shards} total shard files globally, with indices from {first_idx} to {last_idx}."
|
||||
)
|
||||
|
||||
# Calculate the slice of original shard indices assigned to this rank
|
||||
is_distributed = world_size > 1
|
||||
assigned_shard_indices_for_rank = []
|
||||
|
||||
if num_total_shards > 0:
|
||||
if not is_distributed:
|
||||
assigned_shard_indices_for_rank = list(range(num_total_shards))
|
||||
logging.info(
|
||||
f"[Rank {current_global_rank}/{world_size}] Non-distributed mode. Will process all {num_total_shards} shards."
|
||||
)
|
||||
else:
|
||||
num_per_rank_base = num_total_shards // world_size
|
||||
num_with_extra = num_total_shards % world_size
|
||||
|
||||
if current_global_rank < num_with_extra:
|
||||
start_shard_offset = current_global_rank * (num_per_rank_base + 1)
|
||||
num_shards_for_rank = num_per_rank_base + 1
|
||||
else:
|
||||
# Offset by the shards handled by ranks with an extra one
|
||||
start_shard_offset = num_with_extra + current_global_rank * num_per_rank_base
|
||||
num_shards_for_rank = num_per_rank_base
|
||||
|
||||
end_shard_offset = start_shard_offset + num_shards_for_rank
|
||||
assigned_shard_indices_for_rank = list(range(start_shard_offset, end_shard_offset))
|
||||
|
||||
logging.info(
|
||||
f"[Rank {current_global_rank}/{world_size}] Assigned original shard indices "
|
||||
f"{start_shard_offset} through {end_shard_offset -1} "
|
||||
f"({len(assigned_shard_indices_for_rank)} shards)"
|
||||
)
|
||||
|
||||
if not assigned_shard_indices_for_rank:
|
||||
logging.info(
|
||||
f"[Rank {current_global_rank}/{world_size}] No shards assigned to this rank. Returning empty dataloader list. This usually happens when the number of shards is less than the number of ranks."
|
||||
)
|
||||
self._rank_dataloaders = []
|
||||
return []
|
||||
|
||||
# Create DataLoaders only for the shards assigned to this rank
|
||||
dataloaders_for_rank = []
|
||||
for original_shard_idx in tqdm(
|
||||
assigned_shard_indices_for_rank,
|
||||
total=len(assigned_shard_indices_for_rank),
|
||||
desc=f">>> [Rank {current_global_rank}/{world_size}] Creating DataLoaders for its assigned shards",
|
||||
):
|
||||
logging.debug(f"[Rank {current_global_rank}] Processing original shard {original_shard_idx}...")
|
||||
fields = {
|
||||
"cuts": [str(self.cuts_dir / f"cuts.{original_shard_idx:06d}.jsonl.gz")],
|
||||
"recording": [str(self.target_audio_dir / f"recording.{original_shard_idx:06d}.tar")],
|
||||
"context_recording": [str(self.context_audio_dir / f"recording.{original_shard_idx:06d}.tar")],
|
||||
}
|
||||
# Verify if all files exist
|
||||
if not all(Path(shard_filepaths[0]).is_file() for shard_filepaths in fields.values()):
|
||||
err_msg = f"[Rank {current_global_rank}/{world_size}] Missing one or more files for shard {original_shard_idx}. Files: {fields}"
|
||||
logging.error(err_msg)
|
||||
raise FileNotFoundError(err_msg)
|
||||
|
||||
try:
|
||||
logging.debug(
|
||||
f"[Rank {current_global_rank}] Loading CutSet for original shard {original_shard_idx}..."
|
||||
)
|
||||
shard_cutset = CutSet.from_shar(fields=fields)
|
||||
logging.debug(f"[Rank {current_global_rank}] Loaded CutSet for original shard {original_shard_idx}.")
|
||||
except Exception as e:
|
||||
logging.critical(
|
||||
f"[Rank {current_global_rank}/{world_size}] CRITICAL ERROR: Failed to load CutSet from shar for original shard index {original_shard_idx}. \
|
||||
Files attempted: {fields}. \
|
||||
Error: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
logging.debug(f"[Rank {current_global_rank}] Creating Sampler for original shard {original_shard_idx}...")
|
||||
# Explicitly set rank=0, world_size=1 to ensure sampler iterates the whole shard_cutset
|
||||
sampler = SimpleCutSampler(
|
||||
shard_cutset, max_cuts=self.batch_size, shuffle=False, drop_last=False, rank=0, world_size=1
|
||||
)
|
||||
logging.debug(f"[Rank {current_global_rank}] Creating Dataset for original shard {original_shard_idx}...")
|
||||
shard_dataset = AudioPairLhotseDataset(
|
||||
target_sample_rate=self.codec_model.sample_rate,
|
||||
codec_model_samples_per_frame=self.codec_model.samples_per_frame,
|
||||
)
|
||||
logging.debug(f"[Rank {current_global_rank}] Wrapping Dataset for original shard {original_shard_idx}...")
|
||||
iterable_dataset = IterableDatasetWrapper(
|
||||
dataset=shard_dataset,
|
||||
sampler=sampler,
|
||||
)
|
||||
logging.debug(
|
||||
f"[Rank {current_global_rank}] Creating DataLoader for original shard {original_shard_idx}..."
|
||||
)
|
||||
dl = DataLoader(
|
||||
dataset=iterable_dataset,
|
||||
batch_size=None,
|
||||
num_workers=1, # Keep num_workers=1 for `IterableDatasetWrapper + SimpleCutSampler` to avoid duplicate batches.
|
||||
pin_memory=True,
|
||||
)
|
||||
logging.debug(
|
||||
f"[Rank {current_global_rank}] Appending DataLoader for original shard {original_shard_idx}..."
|
||||
)
|
||||
dataloaders_for_rank.append(dl)
|
||||
logging.debug(f"[Rank {current_global_rank}] Finished processing original shard {original_shard_idx}.")
|
||||
|
||||
logging.info(
|
||||
f"[Rank {current_global_rank}/{world_size}] Created {len(dataloaders_for_rank)} DataLoaders for this rank."
|
||||
)
|
||||
# Cache the created dataloaders for this rank
|
||||
self._rank_dataloaders = dataloaders_for_rank
|
||||
return self._rank_dataloaders
|
||||
|
||||
def forward(
|
||||
self,
|
||||
target_audios: torch.Tensor,
|
||||
target_audio_lens: torch.Tensor,
|
||||
context_audios: torch.Tensor,
|
||||
context_audio_lens: torch.Tensor,
|
||||
) -> Optional[Dict[str, torch.Tensor]]:
|
||||
try:
|
||||
target_audios = target_audios.to(self.device)
|
||||
target_audio_lens = target_audio_lens.to(self.device)
|
||||
context_audios = context_audios.to(self.device)
|
||||
context_audio_lens = context_audio_lens.to(self.device)
|
||||
with torch.inference_mode():
|
||||
target_tokens, target_audios_encoded_len = self.codec_model.encode(
|
||||
audio=target_audios, audio_len=target_audio_lens
|
||||
)
|
||||
context_tokens, context_audios_encoded_len = self.codec_model.encode(
|
||||
audio=context_audios, audio_len=context_audio_lens
|
||||
)
|
||||
return {
|
||||
"target_codes": target_tokens.to(dtype=torch.uint16, device="cpu"),
|
||||
"target_codes_lengths": target_audios_encoded_len.to(device="cpu"),
|
||||
"context_codes": context_tokens.to(dtype=torch.uint16, device="cpu"),
|
||||
"context_codes_lengths": context_audios_encoded_len.to(device="cpu"),
|
||||
}
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"[Rank {self.global_rank}/{self.world_size}] Error during batched codec encoding: {e}", exc_info=True
|
||||
)
|
||||
raise e
|
||||
|
||||
def predict_step(
|
||||
self, batch: Dict[str, Any], batch_idx: int, dataloader_idx: int = 0
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
codes_dict = self(
|
||||
target_audios=batch["target_audios"],
|
||||
target_audio_lens=batch["target_audio_lens"],
|
||||
context_audios=batch["context_audios"],
|
||||
context_audio_lens=batch["context_audio_lens"],
|
||||
)
|
||||
|
||||
target_codes_batch = codes_dict["target_codes"]
|
||||
target_codes_lens = codes_dict["target_codes_lengths"]
|
||||
context_codes_batch = codes_dict["context_codes"]
|
||||
context_codes_lens = codes_dict["context_codes_lengths"]
|
||||
|
||||
target_cut_ids = batch["target_cut_id"]
|
||||
shard_indices_in_batch = batch["shard_idx_origin"]
|
||||
|
||||
# The shard_indices list should ideally contain the *same* original index
|
||||
# for all items in a batch, because each DataLoader loads from only one shard.
|
||||
results = []
|
||||
batch_size = batch["target_audios"].shape[0]
|
||||
original_shard_idx = shard_indices_in_batch[0]
|
||||
if not all(idx == original_shard_idx for idx in shard_indices_in_batch):
|
||||
raise ValueError(
|
||||
f"Inconsistent shard indices within batch! Batch Index: {batch_idx}, Dataloader Index: {dataloader_idx}. Indices: {shard_indices_in_batch}."
|
||||
)
|
||||
|
||||
if len(target_cut_ids) != batch_size or target_codes_batch.shape[0] != batch_size:
|
||||
raise ValueError(
|
||||
f"Batch size mismatch after inference! Input IDs: {len(target_cut_ids)}, "
|
||||
f"Input Audio Batch: {batch_size}, Output Codes Batch: {target_codes_batch.shape[0]}. "
|
||||
f"Batch Index: {batch_idx}, Dataloader Index: {dataloader_idx}"
|
||||
)
|
||||
|
||||
for target_cut_id, target_codes, context_codes, target_codes_len, context_codes_len in zip(
|
||||
target_cut_ids, target_codes_batch, context_codes_batch, target_codes_lens, context_codes_lens
|
||||
):
|
||||
results.append(
|
||||
{
|
||||
"target_cut_id": target_cut_id,
|
||||
"shard_idx": original_shard_idx,
|
||||
"target_codes": target_codes[:, :target_codes_len],
|
||||
"context_codes": context_codes[:, :context_codes_len],
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class CodecPredictionWriter(BasePredictionWriter):
|
||||
"""
|
||||
Writes codec predictions (target and context codes) to ArrayTarWriter shards asynchronously.
|
||||
Uses a ThreadPoolExecutor with a single worker to serialize writes and closing operations per shard,
|
||||
allowing potential overlap between prediction computation and I/O while closing writers early.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
output_dir: str,
|
||||
codec_model_name: str,
|
||||
codec_frame_rate: float,
|
||||
):
|
||||
super().__init__(write_interval="batch")
|
||||
self.output_dir_base = Path(output_dir)
|
||||
self.codec_model_name = codec_model_name
|
||||
self.codec_frame_rate = codec_frame_rate
|
||||
self.rank: int = -1
|
||||
self.world_size: int = -1
|
||||
self.target_writers: Dict[int, ArrayTarWriter] = {}
|
||||
self.context_writers: Dict[int, ArrayTarWriter] = {}
|
||||
self.target_codes_dir: Optional[Path] = None
|
||||
self.context_codes_dir: Optional[Path] = None
|
||||
|
||||
# Attributes for asynchronous writing and closing
|
||||
self.writer_lock: Optional[threading.Lock] = None
|
||||
self.bg_worker_thread: Optional[ThreadPoolExecutor] = None
|
||||
self.futures_per_shard: Optional[Dict[int, List[Future]]] = None
|
||||
self.closer_futures: Optional[List[Future]] = None # Futures for the _wait_and_close_worker tasks
|
||||
self.last_processed_shard_idx: int = -1
|
||||
|
||||
def setup(self, trainer: Trainer, pl_module: pl.LightningModule, stage: Optional[str] = None) -> None:
|
||||
self.rank = trainer.global_rank
|
||||
self.world_size = trainer.world_size
|
||||
logging.info(
|
||||
f"[Rank {self.rank}/{self.world_size}] Setting up CodecPredictionWriter for async writing with early close."
|
||||
)
|
||||
|
||||
# Initialize async components
|
||||
self.writer_lock = threading.Lock()
|
||||
# Single worker ensures sequential execution of writes AND closes
|
||||
self.bg_worker_thread = ThreadPoolExecutor(max_workers=1, thread_name_prefix=f'CodecWriterRank{self.rank}')
|
||||
self.futures_per_shard = defaultdict(list)
|
||||
self.closer_futures = []
|
||||
self.last_processed_shard_idx = -1
|
||||
|
||||
# Create directories
|
||||
self.target_codes_dir = self.output_dir_base / self.codec_model_name / "target_codes"
|
||||
self.context_codes_dir = self.output_dir_base / self.codec_model_name / "context_codes"
|
||||
if self.rank == 0:
|
||||
self.target_codes_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.context_codes_dir.mkdir(parents=True, exist_ok=True)
|
||||
if trainer.world_size > 1:
|
||||
torch.distributed.barrier()
|
||||
logging.info(f"[Rank {self.rank}/{self.world_size}] Setup complete. Writers will be created on demand.")
|
||||
|
||||
def _get_or_create_writer(
|
||||
self, writer_dict: Dict[int, ArrayTarWriter], shard_idx: int, base_dir: Path
|
||||
) -> ArrayTarWriter:
|
||||
# Lock needed as this might be called from main thread while closer task modifies dicts
|
||||
with self.writer_lock:
|
||||
if shard_idx not in writer_dict:
|
||||
output_filename = str(base_dir / f"codes.{shard_idx:06d}.tar")
|
||||
logging.debug(
|
||||
f"[Rank {self.rank}/{self.world_size}] Creating writer for shard {shard_idx} (Thread-safe check): {output_filename}"
|
||||
)
|
||||
try:
|
||||
writer = ArrayTarWriter(pattern=output_filename, shard_size=None, compression="numpy")
|
||||
writer.__enter__()
|
||||
writer_dict[shard_idx] = writer
|
||||
logging.info(f"[Rank {self.rank}/{self.world_size}] Created writer for shard {shard_idx}")
|
||||
except Exception as e:
|
||||
msg = f"[Rank {self.rank}/{self.world_size}] Failed to create writer for shard {shard_idx} (file: {output_filename}): {e}"
|
||||
logging.error(msg, exc_info=True)
|
||||
raise ValueError(msg)
|
||||
|
||||
# Return writer even if it might be closed soon by a background task
|
||||
# The background task handles the actual closing.
|
||||
return writer_dict[shard_idx]
|
||||
|
||||
def _write_worker(
|
||||
self,
|
||||
target_cut_id: str,
|
||||
shard_idx: int,
|
||||
target_codes: torch.Tensor,
|
||||
context_codes: torch.Tensor,
|
||||
target_writer: ArrayTarWriter,
|
||||
context_writer: ArrayTarWriter,
|
||||
):
|
||||
"""Worker function executed by the background thread to write a single item."""
|
||||
# Assuming target_writer and context_writer are valid when this task starts
|
||||
try:
|
||||
target_codes_array_manifest = TemporalArray(
|
||||
array=Array(storage_type="shar", storage_path="", storage_key="", shape=list(target_codes.shape)),
|
||||
temporal_dim=-1,
|
||||
frame_shift=1 / self.codec_frame_rate,
|
||||
start=0,
|
||||
)
|
||||
context_codes_array_manifest = TemporalArray(
|
||||
array=Array(storage_type="shar", storage_path="", storage_key="", shape=list(context_codes.shape)),
|
||||
temporal_dim=-1,
|
||||
frame_shift=1 / self.codec_frame_rate,
|
||||
start=0,
|
||||
)
|
||||
target_writer.write(key=target_cut_id, value=target_codes.numpy(), manifest=target_codes_array_manifest)
|
||||
context_writer.write(key=target_cut_id, value=context_codes.numpy(), manifest=context_codes_array_manifest)
|
||||
logging.debug(f"[Worker Rank {self.rank}] Wrote item {target_cut_id} for shard {shard_idx}")
|
||||
except Exception as e:
|
||||
msg = f"[Worker Rank {self.rank}] CRITICAL I/O ERROR writing item {target_cut_id} for shard {shard_idx}: {e}. Writer might be closed prematurely?"
|
||||
logging.error(msg, exc_info=True)
|
||||
raise ValueError(msg)
|
||||
|
||||
def _wait_and_close_worker(self, shard_idx_to_close: int):
|
||||
"""Waits for all write tasks of a shard, then closes and removes its writers."""
|
||||
logging.info(f"[Worker Rank {self.rank}] Starting closure process for shard {shard_idx_to_close}")
|
||||
# 1. Retrieve and remove the list of write futures for this shard
|
||||
# Do this early to prevent new futures being added for this closing shard?
|
||||
# No, write_on_batch_end logic prevents submission for old shards.
|
||||
write_futures = self.futures_per_shard.pop(shard_idx_to_close, [])
|
||||
|
||||
# 2. Wait for all write operations for this shard to complete
|
||||
logging.info(
|
||||
f"[Worker Rank {self.rank}] Waiting for {len(write_futures)} write tasks for shard {shard_idx_to_close}..."
|
||||
)
|
||||
processed_write_futures = 0
|
||||
if write_futures:
|
||||
for f in write_futures:
|
||||
try:
|
||||
f.result() # Wait for completion
|
||||
processed_write_futures += 1
|
||||
except Exception as e:
|
||||
# Write worker already logged this, but log context here
|
||||
logging.error(
|
||||
f"[Worker Rank {self.rank}] Exception during write future.result() for shard {shard_idx_to_close}: {e}",
|
||||
exc_info=False,
|
||||
)
|
||||
logging.info(
|
||||
f"[Worker Rank {self.rank}] Completed {processed_write_futures}/{len(write_futures)} write tasks for shard {shard_idx_to_close}."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"[Worker Rank {self.rank}] No write futures found to wait for shard {shard_idx_to_close} during close."
|
||||
)
|
||||
|
||||
# 3. Safely remove and close the writers
|
||||
writers_closed_count = 0
|
||||
with self.writer_lock: # Protect access to the writer dictionaries
|
||||
target_writer = self.target_writers.pop(shard_idx_to_close, None)
|
||||
context_writer = self.context_writers.pop(shard_idx_to_close, None)
|
||||
|
||||
if target_writer:
|
||||
try:
|
||||
target_writer.close()
|
||||
logging.info(f"[Worker Rank {self.rank}] Closed target writer for shard {shard_idx_to_close}.")
|
||||
writers_closed_count += 1
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"[Worker Rank {self.rank}] Error closing target writer for shard {shard_idx_to_close}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"[Worker Rank {self.rank}] Target writer for shard {shard_idx_to_close} not found during close."
|
||||
)
|
||||
|
||||
if context_writer:
|
||||
try:
|
||||
context_writer.close()
|
||||
logging.info(f"[Worker Rank {self.rank}] Closed context writer for shard {shard_idx_to_close}.")
|
||||
writers_closed_count += 1
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"[Worker Rank {self.rank}] Error closing context writer for shard {shard_idx_to_close}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"[Worker Rank {self.rank}] Context writer for shard {shard_idx_to_close} not found during close."
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"[Worker Rank {self.rank}] Finished closure process for shard {shard_idx_to_close}. Closed {writers_closed_count} writers."
|
||||
)
|
||||
|
||||
def write_on_batch_end(
|
||||
self,
|
||||
trainer: Trainer,
|
||||
pl_module: pl.LightningModule,
|
||||
predictions: Optional[List[Dict[str, Any]]],
|
||||
batch_indices: Optional[List[int]],
|
||||
batch: Any,
|
||||
batch_idx: int,
|
||||
dataloader_idx: int,
|
||||
) -> None:
|
||||
|
||||
if not predictions:
|
||||
err_msg = f"[Rank {self.rank}/{self.world_size}] Received empty predictions list for batch_idx {batch_idx}, dataloader_idx {dataloader_idx}. Skipping."
|
||||
logging.error(err_msg)
|
||||
raise ValueError(err_msg)
|
||||
|
||||
current_shard_idx = predictions[0]["shard_idx"]
|
||||
if not all(p["shard_idx"] == current_shard_idx for p in predictions):
|
||||
raise ValueError(
|
||||
f"[Rank {self.rank}] Inconsistent shard indices within batch! Batch Index: {batch_idx}, Dataloader Index: {dataloader_idx}."
|
||||
)
|
||||
|
||||
# Check for shard change and submit closer task for the previous shard
|
||||
if current_shard_idx != self.last_processed_shard_idx and self.last_processed_shard_idx != -1:
|
||||
logging.info(
|
||||
f"[Rank {self.rank}] Shard index changed from {self.last_processed_shard_idx} to {current_shard_idx}. "
|
||||
f"Submitting closure task for shard {self.last_processed_shard_idx}."
|
||||
)
|
||||
try:
|
||||
closer_future = self.bg_worker_thread.submit(
|
||||
self._wait_and_close_worker, self.last_processed_shard_idx
|
||||
)
|
||||
self.closer_futures.append(closer_future)
|
||||
except Exception as e:
|
||||
msg = f"[Rank {self.rank}] Failed to submit closer task for shard {self.last_processed_shard_idx}: {e}"
|
||||
logging.error(msg, exc_info=True)
|
||||
raise ValueError(msg)
|
||||
|
||||
self.last_processed_shard_idx = current_shard_idx
|
||||
|
||||
# Submit write tasks for each item in the current batch
|
||||
for prediction in predictions:
|
||||
try:
|
||||
target_cut_id = prediction["target_cut_id"]
|
||||
shard_idx = prediction["shard_idx"]
|
||||
target_codes = prediction["target_codes"]
|
||||
context_codes = prediction["context_codes"]
|
||||
|
||||
# This needs the lock because the closer task might be removing entries concurrently
|
||||
target_writer = self._get_or_create_writer(self.target_writers, shard_idx, self.target_codes_dir)
|
||||
context_writer = self._get_or_create_writer(self.context_writers, shard_idx, self.context_codes_dir)
|
||||
|
||||
# Submit the writing task
|
||||
write_future = self.bg_worker_thread.submit(
|
||||
self._write_worker,
|
||||
target_cut_id,
|
||||
shard_idx,
|
||||
target_codes,
|
||||
context_codes,
|
||||
target_writer,
|
||||
context_writer,
|
||||
)
|
||||
self.futures_per_shard[shard_idx].append(write_future)
|
||||
logging.debug(f"[Rank {self.rank}] Submitted write task for item {target_cut_id}, shard {shard_idx}")
|
||||
|
||||
except Exception as e:
|
||||
msg = f"[Rank {self.rank}] Error processing prediction item {prediction.get('target_cut_id', 'UNKNOWN')} from batch {batch_idx}: {e}"
|
||||
logging.error(msg, exc_info=True)
|
||||
raise ValueError(msg)
|
||||
|
||||
def teardown(self, trainer: Trainer, pl_module: pl.LightningModule, stage: Optional[str] = None) -> None:
|
||||
logging.info(
|
||||
f"[Rank {self.rank}/{self.world_size}] Tearing down CodecPredictionWriter. Handling final shard and waiting for closers..."
|
||||
)
|
||||
|
||||
# 1. Submit closer task for the very last processed shard (if any)
|
||||
final_shard_processed = self.last_processed_shard_idx
|
||||
if final_shard_processed != -1 and final_shard_processed in self.futures_per_shard:
|
||||
logging.info(
|
||||
f"[Rank {self.rank}] Submitting final closure task for last processed shard {final_shard_processed}."
|
||||
)
|
||||
try:
|
||||
closer_future = self.bg_worker_thread.submit(self._wait_and_close_worker, final_shard_processed)
|
||||
self.closer_futures.append(closer_future)
|
||||
except Exception as e:
|
||||
msg = f"[Rank {self.rank}] Failed to submit final closer task for shard {final_shard_processed}: {e}"
|
||||
logging.error(msg, exc_info=True)
|
||||
raise ValueError(msg)
|
||||
|
||||
# 2. Wait for all closer tasks to complete
|
||||
num_closer_futures = len(self.closer_futures)
|
||||
logging.info(
|
||||
f"[Rank {self.rank}/{self.world_size}] Waiting for {num_closer_futures} background closer tasks to complete."
|
||||
)
|
||||
processed_closer_futures = 0
|
||||
if self.closer_futures:
|
||||
for future in tqdm(
|
||||
self.closer_futures,
|
||||
total=num_closer_futures,
|
||||
desc=f"[Rank {self.rank}/{self.world_size}] Finalizing Shard Closures",
|
||||
leave=False,
|
||||
):
|
||||
try:
|
||||
future.result() # Wait and check for exceptions from the closer worker
|
||||
processed_closer_futures += 1
|
||||
except Exception as e:
|
||||
msg = f"[Rank {self.rank}/{self.world_size}] Exception caught during closer future.result(): {e}"
|
||||
logging.error(msg, exc_info=True)
|
||||
raise ValueError(msg)
|
||||
|
||||
logging.info(
|
||||
f"[Rank {self.rank}/{self.world_size}] Completed {processed_closer_futures}/{num_closer_futures} closer tasks."
|
||||
)
|
||||
else:
|
||||
logging.info(f"[Rank {self.rank}/{self.world_size}] No closer tasks were submitted.")
|
||||
|
||||
# 3. Shutdown the executor gracefully (all tasks should be done now)
|
||||
if self.bg_worker_thread:
|
||||
logging.info(f"[Rank {self.rank}/{self.world_size}] Shutting down background worker thread.")
|
||||
self.bg_worker_thread.shutdown(wait=True)
|
||||
self.bg_worker_thread = None
|
||||
|
||||
# 4. Final sanity checks and cleanup
|
||||
remaining_writers = len(self.target_writers) + len(self.context_writers)
|
||||
if remaining_writers > 0:
|
||||
msg = f"[Rank {self.rank}/{self.world_size}] {remaining_writers} writers remain after teardown! This should not happen. Keys: Target {list(self.target_writers.keys())}, Context {list(self.context_writers.keys())}"
|
||||
logging.error(msg)
|
||||
raise ValueError(msg)
|
||||
|
||||
remaining_futures = sum(len(futs) for futs in self.futures_per_shard.values())
|
||||
if remaining_futures > 0:
|
||||
msg = f"[Rank {self.rank}/{self.world_size}] {remaining_futures} write futures remain after teardown! This should not happen. Shards: {list(self.futures_per_shard.keys())}"
|
||||
logging.error(msg)
|
||||
raise ValueError(msg)
|
||||
|
||||
self.target_writers.clear()
|
||||
self.context_writers.clear()
|
||||
self.futures_per_shard.clear()
|
||||
self.closer_futures.clear()
|
||||
|
||||
logging.info(f"[Rank {self.rank}/{self.world_size}] Teardown complete.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--cuts-dir", type=str, required=True, help="Directory containing input cuts/cuts.*.jsonl.gz shards."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target-audio-dir", type=str, required=True, help="Directory containing target_audio/recording.*.tar shards."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--context-audio-dir",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Directory containing context_audio/recording.*.tar shards.",
|
||||
)
|
||||
parser.add_argument("--output-dir", type=str, required=True, help="Base directory to save the output code shards.")
|
||||
parser.add_argument(
|
||||
"--codec-model-name",
|
||||
type=str,
|
||||
default="21fpsCausalDecoder",
|
||||
help="Name for codec model (used in output path).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--codec-model-path", type=str, required=True, help="Path to the NeMo codec model (.nemo file)."
|
||||
)
|
||||
parser.add_argument("--codec-frame-rate", type=float, default=21.5, help="Frame rate for codec model.")
|
||||
parser.add_argument("--devices", type=int, default=-1, help="Number of GPUs per node (-1 for all).")
|
||||
parser.add_argument("--num-nodes", type=int, default=1, help="Number of nodes for distributed processing.")
|
||||
parser.add_argument("--batch-size", type=int, default=32, help="Batch size PER GPU for codec inference.")
|
||||
parser.add_argument(
|
||||
"--buffer-size", type=int, default=256, help="Number of items to buffer before writing to TAR files."
|
||||
)
|
||||
parser.add_argument("--wandb-entity", type=str, default=None, help="Wandb entity.")
|
||||
parser.add_argument("--wandb-project", type=str, default="lhotse_codes_extraction", help="Wandb project.")
|
||||
parser.add_argument("--wandb-name", type=str, default=None, help="Wandb run name.")
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
type=str,
|
||||
default="INFO",
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
help="Set the logging level.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
log_level_val = getattr(logging, args.log_level.upper(), logging.INFO)
|
||||
log_format = '%(asctime)s - PID:%(process)d - %(levelname)s - %(message)s'
|
||||
logging.basicConfig(level=log_level_val, format=log_format)
|
||||
|
||||
codec_extractor = CodecExtractor(
|
||||
model_path=args.codec_model_path,
|
||||
cuts_dir=args.cuts_dir,
|
||||
target_audio_dir=args.target_audio_dir,
|
||||
context_audio_dir=args.context_audio_dir,
|
||||
batch_size=args.batch_size,
|
||||
)
|
||||
|
||||
pred_writer = CodecPredictionWriter(
|
||||
output_dir=args.output_dir,
|
||||
codec_model_name=args.codec_model_name,
|
||||
codec_frame_rate=args.codec_frame_rate,
|
||||
)
|
||||
|
||||
wandb_logger = None
|
||||
if args.wandb_entity and args.wandb_project:
|
||||
wandb_logger = WandbLogger(
|
||||
project=args.wandb_project,
|
||||
entity=args.wandb_entity,
|
||||
name=args.wandb_name or f"extract_codes_{args.codec_model_name}_{os.path.basename(args.cuts_dir)}",
|
||||
log_model=False,
|
||||
)
|
||||
logging.info(f"Wandb logging enabled to {args.wandb_entity}/{args.wandb_project}")
|
||||
|
||||
strategy = DDPStrategy(find_unused_parameters=False) if torch.cuda.is_available() and args.devices != 1 else "auto"
|
||||
trainer = Trainer(
|
||||
devices=args.devices if torch.cuda.is_available() else 1,
|
||||
num_nodes=args.num_nodes,
|
||||
accelerator="gpu" if torch.cuda.is_available() else "cpu",
|
||||
strategy=strategy,
|
||||
logger=wandb_logger,
|
||||
callbacks=[pred_writer],
|
||||
use_distributed_sampler=False, # we should disable replacing or wrapping Lhostse CutSampler with a `DistributedSamplerWrapper` since Lhotse's sampler already handles distributed sampling.
|
||||
)
|
||||
|
||||
logging.info(f"Starting prediction with {trainer.world_size} ranks.")
|
||||
trainer.predict(codec_extractor, return_predictions=False)
|
||||
logging.info("Prediction finished.")
|
||||
|
||||
if trainer.is_global_zero and wandb_logger:
|
||||
wandb.finish()
|
||||
logging.info("Wandb run finished.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import torch.multiprocessing
|
||||
|
||||
try:
|
||||
torch.multiprocessing.set_start_method('spawn')
|
||||
except RuntimeError:
|
||||
# This exception occurs if the start method has already been set. We can safely ignore it.
|
||||
pass
|
||||
main()
|
||||
@@ -0,0 +1,27 @@
|
||||
# 🧠 TopIPL: Iterative Pseudo-Labeling for ASR
|
||||
|
||||
TopIPL is an **iterative pseudo-labeling algorithm** for training speech recognition models using both labeled and unlabeled data. It integrates seamlessly into the NeMo ASR pipeline and enables **self-training** across epochs with minimal manual intervention.
|
||||
|
||||
## 🚀 Key Features
|
||||
|
||||
- ⚙️ Supports **semi-supervised ASR training** with dynamic iterative pseudo-label refinement.
|
||||
- 🧪 Designed for large-scale training using both labeled and unlabeled speech data.
|
||||
- 🔁 Automatically writes pseudo-labels and updates training configs between iterations.
|
||||
|
||||
## 📦 Required Components
|
||||
|
||||
TopIPL relies on the following components:
|
||||
|
||||
- **[`SDPNeMoRunIPLProcessor`]**
|
||||
Commands for running IPL are generated and submitted using SDP processors and NeMo-Run.
|
||||
See instructions for usage [here](https://github.com/NVIDIA/NeMo-speech-data-processor/blob/main/sdp/processors/ipl/README.md).
|
||||
|
||||
- **Training Callback: `IPLEpochStopperCallback`**
|
||||
Add this to your training config under `exp_manager` to **stop training at the end of each epoch**, enabling pseudo-label update:
|
||||
|
||||
```yaml
|
||||
exp_manager:
|
||||
create_ipl_epoch_stopper_callback: True
|
||||
ipl_epoch_stopper_callback_params:
|
||||
stop_every_n_epochs: n # Stop training after every n epochs (default: 1)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
import argparse
|
||||
import glob
|
||||
import math
|
||||
import os
|
||||
from typing import List, Union
|
||||
|
||||
|
||||
from filelock import FileLock
|
||||
from omegaconf import ListConfig, OmegaConf
|
||||
|
||||
|
||||
def count_files_for_tarred_pseudo_labeling(manifest_filepath: Union[str, ListConfig]) -> int:
|
||||
"""
|
||||
Counts the total number of entries across multiple manifest files.
|
||||
Args:
|
||||
manifest_filepath (Union[str, ListConfig]): The file path to the manifest files.
|
||||
Returns:
|
||||
int: The total number of entries across all matching manifest files.
|
||||
"""
|
||||
# Convert ListConfig to string if needed
|
||||
if isinstance(manifest_filepath, ListConfig):
|
||||
manifest_filepath = manifest_filepath[0] # Use the first element if it's a list or ListConfig
|
||||
dir_path, filename = os.path.split(manifest_filepath)
|
||||
prefix = filename.split('_', 1)[0]
|
||||
number_of_files = 0
|
||||
for full_path in glob.glob(os.path.join(dir_path, f"{prefix}_[0-9]*.json")):
|
||||
with open(full_path, 'r') as f:
|
||||
number_of_files += len(f.readlines())
|
||||
return number_of_files
|
||||
|
||||
|
||||
def count_files_for_pseudo_labeling(manifest_filepath: Union[str, list, ListConfig]) -> int:
|
||||
"""
|
||||
Counts the number of entries in a single manifest file .
|
||||
Args:
|
||||
manifest_filepath (Union[str, list, ListConfig]): The file path to the manifest file.
|
||||
Returns:
|
||||
int: The total number of entries (lines) in the manifest file.
|
||||
"""
|
||||
# Convert ListConfig to string if needed
|
||||
if isinstance(manifest_filepath, list) or isinstance(manifest_filepath, ListConfig):
|
||||
manifest_filepath = manifest_filepath[0]
|
||||
with open(manifest_filepath, 'r') as f:
|
||||
number_of_files = len(f.readlines())
|
||||
return number_of_files
|
||||
|
||||
|
||||
def export_limit_predict_batches(inference_configs: List[str], p_cache: float, num_gpus: int) -> None:
|
||||
"""
|
||||
Updates inference configuration files to set `limit_predict_batches`.
|
||||
This is done to force partial transcription of unlabeled dataset for dynamic update of PLs.
|
||||
|
||||
Args:
|
||||
inference_configs (List[str]): A list of file paths to the inference configuration files.
|
||||
p_cache (float): A scaling factor for the cache to adjust the number of batches.
|
||||
num_gpus (int): The number of GPUs available for inference.
|
||||
|
||||
Returns:
|
||||
None: The function modifies and saves the updated configuration files in-place.
|
||||
"""
|
||||
for config_path in inference_configs:
|
||||
config = OmegaConf.load(config_path)
|
||||
tarred_audio_filepaths = config.predict_ds.get("tarred_audio_filepaths", None)
|
||||
manifest_filepaths = config.predict_ds.manifest_filepath
|
||||
|
||||
if tarred_audio_filepaths:
|
||||
number_of_files = count_files_for_tarred_pseudo_labeling(manifest_filepaths)
|
||||
else:
|
||||
number_of_files = count_files_for_pseudo_labeling(manifest_filepaths)
|
||||
|
||||
if hasattr(config.predict_ds, "batch_size"):
|
||||
batch_size = config.predict_ds.batch_size
|
||||
limit_predict_batches = math.ceil((number_of_files * p_cache) / (batch_size * num_gpus))
|
||||
OmegaConf.update(config, "trainer.limit_predict_batches", limit_predict_batches)
|
||||
OmegaConf.save(config, config_path)
|
||||
elif hasattr(config.predict_ds, "batch_duration"):
|
||||
batch_duration = config.predict_ds.batch_duration
|
||||
average_audio_len = 10
|
||||
limit_predict_batches = math.ceil(
|
||||
(number_of_files * average_audio_len * p_cache) / (batch_duration * num_gpus)
|
||||
)
|
||||
OmegaConf.update(config, "trainer.limit_predict_batches", limit_predict_batches)
|
||||
OmegaConf.save(config, config_path)
|
||||
else:
|
||||
batch_size = 32
|
||||
limit_predict_batches = math.ceil((number_of_files * p_cache) / (batch_size * num_gpus))
|
||||
OmegaConf.update(config, "trainer.limit_predict_batches", limit_predict_batches)
|
||||
OmegaConf.save(config, config_path)
|
||||
|
||||
|
||||
def main():
|
||||
rank = int(os.environ.get("RANK", 0)) # Default to 0 if not set
|
||||
|
||||
# Ensure only one process executes this block
|
||||
parser = argparse.ArgumentParser(description="Export limit_predict_batches as environment variables.")
|
||||
parser.add_argument(
|
||||
"--inference_configs",
|
||||
type=str,
|
||||
nargs='+', # Accepts one or more values as a list
|
||||
required=True,
|
||||
help="Paths to one or more inference config YAML files.",
|
||||
)
|
||||
parser.add_argument("--p_cache", type=float, required=True, help="Pseudo-label cache fraction.")
|
||||
parser.add_argument("--num_gpus", type=int, required=True, help="Number of GPUs available.")
|
||||
|
||||
args = parser.parse_args()
|
||||
lock_dir = os.path.dirname(args.inference_configs[0])
|
||||
lock_file = lock_dir + "/my_script.lock"
|
||||
# Code executed by all processes
|
||||
|
||||
# # Code executed by a single process
|
||||
with FileLock(lock_file):
|
||||
if rank == 0:
|
||||
export_limit_predict_batches(
|
||||
inference_configs=args.inference_configs, p_cache=args.p_cache, num_gpus=args.num_gpus
|
||||
)
|
||||
|
||||
# Remove the lock file after the FileLock context is exited
|
||||
if os.path.exists(lock_file):
|
||||
os.remove(lock_file)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,260 @@
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
|
||||
from filelock import FileLock
|
||||
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
def create_transcribed_shard_manifests(prediction_filepaths: List[str]) -> List[str]:
|
||||
"""
|
||||
Creates transcribed shard manifest files by processing predictions and organizing them by shard ID.
|
||||
|
||||
This function reads a `predictions_all.json` file from each given directory, organizes the data by
|
||||
shard IDs, and writes the entries to separate shard manifest files. For each shard, the `pred_text`
|
||||
field is updated as the main transcription (`text`), and the original transcription (`text`) is
|
||||
stored as `orig_text`.
|
||||
|
||||
Args:
|
||||
prediction_filepaths (List[str]): A list of file paths to directories containing
|
||||
`predictions_all.json` files with prediction data, including shard IDs.
|
||||
|
||||
Returns:
|
||||
List[str]: A list of file paths to the combined manifest files (`transcribed_manifest__OP_0..CL_.json`)
|
||||
created for each directory.
|
||||
"""
|
||||
all_manifest_filepaths = []
|
||||
for prediction_filepath in prediction_filepaths:
|
||||
max_shard_id = 0
|
||||
shard_data = {}
|
||||
full_path = os.path.join(prediction_filepath, "predictions_all.json")
|
||||
with open(full_path, 'r') as f:
|
||||
for line in f.readlines():
|
||||
data_entry = json.loads(line)
|
||||
shard_id = data_entry.get("shard_id")
|
||||
if max_shard_id < shard_id:
|
||||
max_shard_id = shard_id
|
||||
if shard_id not in shard_data:
|
||||
shard_data[shard_id] = []
|
||||
shard_data[shard_id].append(data_entry)
|
||||
for shard_id, entries in shard_data.items():
|
||||
output_filename = os.path.join(prediction_filepath, f"transcribed_manifest_{shard_id}.json")
|
||||
with open(output_filename, 'w') as f:
|
||||
for data_entry in entries:
|
||||
if data_entry['audio_filepath'].endswith(".wav"):
|
||||
if 'text' in data_entry:
|
||||
data_entry['orig_text'] = data_entry.pop('text')
|
||||
data_entry['text'] = data_entry.pop('pred_text')
|
||||
json.dump(data_entry, f, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
shard_manifest_filepath = os.path.join(
|
||||
prediction_filepath, f"transcribed_manifest__OP_0..{max_shard_id}_CL_.json"
|
||||
)
|
||||
all_manifest_filepaths.append(shard_manifest_filepath)
|
||||
return all_manifest_filepaths
|
||||
|
||||
|
||||
def create_transcribed_manifests(prediction_filepaths: List[str]) -> List[str]:
|
||||
"""
|
||||
Creates updated transcribed manifest files by processing predictions.
|
||||
|
||||
This function reads prediction files (`predictions_all.json`) from the provided directories,
|
||||
updates the transcription data by renaming the `pred_text` field to `text`, and stores the
|
||||
original `text` field as `orig_text`. The updated data is written to new transcribed manifest
|
||||
files (`transcribed_manifest.json`) in each directory.
|
||||
|
||||
Args:
|
||||
prediction_filepaths (List[str]): A list of file paths to directories containing
|
||||
prediction files (`predictions_all.json`).
|
||||
|
||||
Returns:
|
||||
List[str]: A list of file paths to the newly created transcribed manifest files
|
||||
(`transcribed_manifest.json`).
|
||||
"""
|
||||
all_manifest_filepaths = []
|
||||
for prediction_filepath in prediction_filepaths:
|
||||
prediction_name = os.path.join(prediction_filepath, "predictions_all.json")
|
||||
transcripted_name = os.path.join(prediction_filepath, f"transcribed_manifest.json")
|
||||
|
||||
# Open and read the original predictions_all.json file
|
||||
with open(transcripted_name, 'w', encoding='utf-8') as f:
|
||||
with open(prediction_name, 'r', encoding='utf-8') as pred_f:
|
||||
|
||||
for line in pred_f.readlines():
|
||||
data_entry = json.loads(line)
|
||||
if 'text' in data_entry:
|
||||
data_entry['orig_text'] = data_entry.pop('text')
|
||||
data_entry['text'] = data_entry.pop('pred_text')
|
||||
json.dump(data_entry, f, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
# Append the path of the new manifest file to the list
|
||||
all_manifest_filepaths.append(transcripted_name)
|
||||
|
||||
return all_manifest_filepaths
|
||||
|
||||
|
||||
def write_sampled_shard_transcriptions(manifest_filepaths: List[str]) -> List[List[str]]:
|
||||
"""
|
||||
Updates transcriptions by merging predicted shard data and transcribed manifest data.
|
||||
This function processes prediction and transcribed manifest files, merges them
|
||||
by matching the shard_id and audio file paths. For each shard, the corresponding
|
||||
data entries are written to a new file.
|
||||
Args:
|
||||
manifest_filepaths (List[str]): A list of file paths to directories containing
|
||||
prediction and transcribed manifest files.
|
||||
Returns:
|
||||
List[List[str]]: A list of lists containing the file paths to the generated
|
||||
transcribed shard manifest files.
|
||||
"""
|
||||
all_manifest_filepaths = []
|
||||
|
||||
# Process each prediction directory
|
||||
for prediction_filepath in manifest_filepaths:
|
||||
predicted_shard_data = {}
|
||||
# Collect entries from prediction files based on shard id
|
||||
prediction_path = os.path.join(prediction_filepath, "predictions_all.json")
|
||||
with open(prediction_path, 'r') as f:
|
||||
for line in f:
|
||||
data_entry = json.loads(line)
|
||||
shard_id = data_entry.get("shard_id")
|
||||
audio_filepath = data_entry['audio_filepath']
|
||||
predicted_shard_data.setdefault(shard_id, {})[audio_filepath] = data_entry
|
||||
max_shard_id = 0
|
||||
for full_path in glob.glob(os.path.join(prediction_filepath, f"transcribed_manifest_[0-9]*.json")):
|
||||
all_data_entries = []
|
||||
with open(full_path, 'r') as f:
|
||||
for line in f:
|
||||
data_entry = json.loads(line)
|
||||
shard_id = data_entry.get("shard_id")
|
||||
max_shard_id = max(max_shard_id, shard_id)
|
||||
all_data_entries.append(data_entry)
|
||||
# Write the merged data to a new manifest file keeping new transcriptions
|
||||
output_filename = os.path.join(prediction_filepath, f"transcribed_manifest_{shard_id}.json")
|
||||
with open(output_filename, 'w') as f:
|
||||
for data_entry in all_data_entries:
|
||||
audio_filepath = data_entry['audio_filepath']
|
||||
# Escape duplicated audio files that end with *dup
|
||||
if audio_filepath.endswith(".wav"):
|
||||
if shard_id in predicted_shard_data and audio_filepath in predicted_shard_data[shard_id]:
|
||||
predicted_data_entry = predicted_shard_data[shard_id][audio_filepath]
|
||||
if 'text' in predicted_data_entry:
|
||||
predicted_data_entry['orig_text'] = predicted_data_entry.pop('text')
|
||||
if "pred_text" in predicted_data_entry:
|
||||
predicted_data_entry['text'] = predicted_data_entry.pop('pred_text')
|
||||
json.dump(predicted_data_entry, f, ensure_ascii=False)
|
||||
else:
|
||||
json.dump(data_entry, f, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
shard_manifest_filepath = os.path.join(prediction_filepath, f"transcribed_manifest__OP_0..{max_shard_id}_CL_.json")
|
||||
all_manifest_filepaths.append([shard_manifest_filepath])
|
||||
|
||||
return all_manifest_filepaths
|
||||
|
||||
|
||||
def write_sampled_transcriptions(manifest_filepaths: List[str]) -> List[str]:
|
||||
"""
|
||||
Updates transcriptions by merging predicted data with transcribed manifest data.
|
||||
|
||||
This function processes prediction and transcribed manifest files within given directories.
|
||||
It matches audio file paths to update transcriptions with predictions, ensuring each audio file
|
||||
is properly transcribed. The updated data is written to the transcribed manifest file.
|
||||
|
||||
Args:
|
||||
manifest_filepaths (List[str]): A list of file paths to directories containing
|
||||
the prediction file (`predictions_all.json`) and the transcribed manifest file
|
||||
(`transcribed_manifest.json`).
|
||||
|
||||
Returns:
|
||||
List[str]: A list of file paths to the updated transcribed manifest files.
|
||||
"""
|
||||
all_manifest_filepaths = []
|
||||
for prediction_filepath in manifest_filepaths:
|
||||
predicted_data = {}
|
||||
prediction_path = os.path.join(prediction_filepath, "predictions_all.json")
|
||||
with open(prediction_path, 'r') as f:
|
||||
for line in f:
|
||||
data_entry = json.loads(line)
|
||||
path = data_entry['audio_filepath']
|
||||
predicted_data[path] = data_entry
|
||||
|
||||
full_path = os.path.join(prediction_filepath, f"transcribed_manifest.json")
|
||||
all_data_entries = []
|
||||
with open(full_path, 'r') as f:
|
||||
for line in f:
|
||||
data_entry = json.loads(line)
|
||||
all_data_entries.append(data_entry)
|
||||
|
||||
output_filename = os.path.join(prediction_filepath, f"transcribed_manifest.json")
|
||||
with open(output_filename, 'w') as f:
|
||||
for data_entry in all_data_entries:
|
||||
audio_filepath = data_entry['audio_filepath']
|
||||
if audio_filepath.endswith(".wav"):
|
||||
if audio_filepath in predicted_data:
|
||||
predicted_data_entry = predicted_data[audio_filepath]
|
||||
if 'text' in predicted_data_entry:
|
||||
predicted_data_entry['orig_text'] = predicted_data_entry.pop('text')
|
||||
predicted_data_entry['text'] = predicted_data_entry.pop('pred_text')
|
||||
json.dump(predicted_data_entry, f, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
else:
|
||||
json.dump(data_entry, f, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
all_manifest_filepaths.append(output_filename)
|
||||
return all_manifest_filepaths
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
rank = int(os.environ.get("RANK", 0)) # Default to 0 if not set
|
||||
|
||||
parser = argparse.ArgumentParser(description="Script to create or write transcriptions")
|
||||
parser.add_argument("--is_tarred", action="store_true", help="If true, processes tarred manifests")
|
||||
parser.add_argument("--full_pass", action="store_true", help="If true, processes full pass manifests")
|
||||
parser.add_argument(
|
||||
"--prediction_filepaths",
|
||||
type=str,
|
||||
nargs='+', # Accepts one or more values as a list
|
||||
required=True,
|
||||
help="Paths to one or more inference config YAML files.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
lock_dir = os.path.dirname(args.prediction_filepaths[0])
|
||||
lock_file = lock_dir + "/my_script.lock"
|
||||
|
||||
with FileLock(lock_file):
|
||||
if rank == 0:
|
||||
if args.is_tarred:
|
||||
result = (
|
||||
write_sampled_shard_transcriptions(args.prediction_filepaths)
|
||||
if not args.full_pass
|
||||
else create_transcribed_shard_manifests(args.prediction_filepaths)
|
||||
)
|
||||
else:
|
||||
result = (
|
||||
write_sampled_transcriptions(args.prediction_filepaths)
|
||||
if not args.full_pass
|
||||
else create_transcribed_manifests(args.prediction_filepaths)
|
||||
)
|
||||
|
||||
# Remove the lock file after the FileLock context is exited
|
||||
if os.path.exists(lock_file):
|
||||
os.remove(lock_file)
|
||||
@@ -0,0 +1,388 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import get_ctm_line, read_manifest, write_ctm, write_manifest
|
||||
from nemo.utils import logging
|
||||
|
||||
|
||||
def get_seg_info_from_ctm_line(
|
||||
ctm_list: List[str],
|
||||
output_precision: int,
|
||||
speaker_index: int = 7,
|
||||
start_time_index: int = 2,
|
||||
duration_index: int = 3,
|
||||
):
|
||||
"""
|
||||
Get time stamp information and speaker labels from CTM lines.
|
||||
This is following CTM format appeared in `Rich Transcription Meeting Eval Plan: RT09` document.
|
||||
|
||||
CTM Format:
|
||||
<SOURCE>< <CHANNEL> <BEG-TIME> <DURATION> <TOKEN> <CONF> <TYPE> <SPEAKER>
|
||||
|
||||
Args:
|
||||
ctm_list (list): List containing CTM items. e.g.: ['sw02001-A', '1', '0.000', '0.200', 'hello', '0.98', 'lex', 'speaker3']
|
||||
output_precision (int): Precision for CTM outputs in integer.
|
||||
|
||||
Returns:
|
||||
start (float): Start time of the segment.
|
||||
end (float): End time of the segment.
|
||||
speaker_id (str): Speaker ID of the segment.
|
||||
"""
|
||||
speaker_id = ctm_list[speaker_index]
|
||||
start = float(ctm_list[start_time_index])
|
||||
end = float(ctm_list[start_time_index]) + float(ctm_list[duration_index])
|
||||
start = round(start, output_precision)
|
||||
end = round(end, output_precision)
|
||||
if type(speaker_id) == str:
|
||||
speaker_id = speaker_id.strip()
|
||||
return start, end, speaker_id
|
||||
|
||||
|
||||
def get_unaligned_files(unaligned_path: str) -> List[str]:
|
||||
"""
|
||||
Get files without alignments in order to filter them out (as they cannot be used for data simulation).
|
||||
In the unaligned file, each line contains the file name and the reason for the unalignment, if necessary to specify.
|
||||
|
||||
Example: unaligned.txt
|
||||
|
||||
<utterance_id> <comment>
|
||||
1272-128104-0000 (no such file)
|
||||
2289-152257-0025 (no such file)
|
||||
2289-152257-0026 (mapping failed)
|
||||
...
|
||||
|
||||
Args:
|
||||
unaligned_path (str): Path to the file containing unaligned examples
|
||||
|
||||
Returns:
|
||||
skip_files (list): Unaligned file names to skip
|
||||
"""
|
||||
skip_files = []
|
||||
with open(unaligned_path, 'r', encoding='utf-8') as f:
|
||||
for line in f.readlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
unaligned_file = line.split()[0]
|
||||
skip_files.append(unaligned_file)
|
||||
return skip_files
|
||||
|
||||
|
||||
def get_new_ctm_lines_from_alignments(session_name, speaker_id, wordlist, alignments, output_precision=3) -> List[str]:
|
||||
"""
|
||||
Create new CTM entry (to write to output ctm file)
|
||||
|
||||
Args:
|
||||
session_name (str): Current session name.
|
||||
speaker_id (int): LibriSpeech speaker ID for the current entry.
|
||||
wordlist (list): List of words
|
||||
alignments (list): List of alignments
|
||||
output_precision (int): Precision for CTM outputs
|
||||
Returns:
|
||||
arr (list): List of ctm entries, each entry is a tuple of (start_time, text)
|
||||
"""
|
||||
arr = []
|
||||
for i in range(len(wordlist)):
|
||||
word = wordlist[i]
|
||||
if word != "":
|
||||
# note that using the current alignments the first word is always empty, so there is no error from indexing the array with i-1
|
||||
align1 = float(round(alignments[i - 1], output_precision))
|
||||
align2 = float(
|
||||
round(
|
||||
alignments[i] - alignments[i - 1],
|
||||
output_precision,
|
||||
)
|
||||
)
|
||||
text = get_ctm_line(
|
||||
source=session_name,
|
||||
channel=speaker_id,
|
||||
start_time=align1,
|
||||
duration=align2,
|
||||
token=word,
|
||||
conf=None,
|
||||
type_of_token='lex',
|
||||
speaker=speaker_id,
|
||||
output_precision=output_precision,
|
||||
)
|
||||
arr.append((align1, text))
|
||||
return arr
|
||||
|
||||
|
||||
def load_librispeech_alignment(alignment_filepath: str) -> dict:
|
||||
"""
|
||||
Load alignment data for librispeech
|
||||
|
||||
Args:
|
||||
alignment_filepath (str): Path to the file containing alignments
|
||||
Returns:
|
||||
alignments (dict[tuple]): A dictionary containing file index and alignments
|
||||
"""
|
||||
alignments = {}
|
||||
with open(alignment_filepath, "r") as fin:
|
||||
for line in fin.readlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
file_id, words, timestamps = line.split()
|
||||
alignments[file_id] = (words, timestamps)
|
||||
return alignments
|
||||
|
||||
|
||||
def create_librispeech_ctm_alignments(
|
||||
input_manifest_filepath, base_alignment_path, ctm_output_directory, libri_dataset_split
|
||||
):
|
||||
"""
|
||||
Create new CTM alignments using input LibriSpeech word alignments.
|
||||
|
||||
Args:
|
||||
input_manifest_filepath (str): Path to the input LibriSpeech manifest file
|
||||
base_alignment_path (str): Path to the base directory containing the LibriSpeech word alignments
|
||||
ctm_source_dir (str): Directory to write the CTM files to
|
||||
libri_dataset_split (str): Which split of the LibriSpeech dataset is being used
|
||||
"""
|
||||
manifest = read_manifest(input_manifest_filepath)
|
||||
unaligned_path = os.path.join(base_alignment_path, "unaligned.txt")
|
||||
|
||||
if os.path.exists(unaligned_path):
|
||||
unaligned_file_ids = set(get_unaligned_files(unaligned_path))
|
||||
else:
|
||||
unaligned_file_ids = set()
|
||||
|
||||
libri_dataset_split = libri_dataset_split.replace("_", "-")
|
||||
|
||||
# delete output directory if it exists or throw warning
|
||||
if os.path.isdir(ctm_output_directory):
|
||||
logging.info(f"Removing existing output directory: {ctm_output_directory}")
|
||||
shutil.rmtree(ctm_output_directory)
|
||||
if not os.path.exists(ctm_output_directory):
|
||||
logging.info(f"Creating output directory: {ctm_output_directory}")
|
||||
os.mkdir(ctm_output_directory)
|
||||
|
||||
if len(manifest) == 0:
|
||||
raise Exception(f"Input manifest is empty: {input_manifest_filepath}")
|
||||
|
||||
for entry in manifest:
|
||||
audio_file = entry['audio_filepath']
|
||||
file_id = Path(audio_file).stem
|
||||
|
||||
if file_id in unaligned_file_ids:
|
||||
continue
|
||||
|
||||
speaker_id = file_id.split('-')[0]
|
||||
book_id = file_id.split('-')[1]
|
||||
book_dir = os.path.join(base_alignment_path, "LibriSpeech", libri_dataset_split, speaker_id, book_id)
|
||||
alignment_filepath = os.path.join(book_dir, f"{speaker_id}-{book_id}.alignment.txt")
|
||||
|
||||
alignment_data = load_librispeech_alignment(alignment_filepath)
|
||||
if file_id not in alignment_data:
|
||||
logging.warning(f"Cannot find alignment data for {audio_file} in {alignment_filepath}")
|
||||
continue
|
||||
|
||||
words, end_times = alignment_data[file_id]
|
||||
words = words.replace('\"', '').lower().split(',')
|
||||
end_times = [float(e) for e in end_times.replace('\"', '').split(',')]
|
||||
ctm_list = get_new_ctm_lines_from_alignments(file_id, speaker_id, words, end_times)
|
||||
write_ctm(os.path.join(ctm_output_directory, file_id + '.ctm'), ctm_list)
|
||||
|
||||
|
||||
def create_manifest_with_alignments(
|
||||
input_manifest_filepath,
|
||||
ctm_source_dir,
|
||||
output_manifest_filepath,
|
||||
data_format_style,
|
||||
silence_dur_threshold=0.1,
|
||||
output_precision=3,
|
||||
):
|
||||
"""
|
||||
Create new manifest file with word alignments using CTM files
|
||||
|
||||
Args:
|
||||
input_manifest_filepath (str): Path to the input manifest file
|
||||
ctm_source_dir (str): Directory to read the CTM files from
|
||||
output_manifest_filepath (str): Path to the output manifest file containing word alignments
|
||||
precision (int): How many decimal places to keep in the manifest file
|
||||
"""
|
||||
manifest = read_manifest(input_manifest_filepath)
|
||||
|
||||
target_manifest = []
|
||||
src_i = 0
|
||||
tgt_i = 0
|
||||
while src_i < len(manifest):
|
||||
f = manifest[src_i]
|
||||
fn = f['audio_filepath'].split('/')[-1]
|
||||
filename = fn.split('.')[0] # assuming that there is only one period in the input filenames
|
||||
if "voxceleb" in data_format_style:
|
||||
fn_split = f['audio_filepath'].split('/')
|
||||
filename = fn_split[-3] + '-' + fn_split[-2] + '-' + fn_split[-1].split('.')[0]
|
||||
ctm_filepath = os.path.join(ctm_source_dir, filename + '.ctm')
|
||||
else:
|
||||
ctm_filepath = os.path.join(ctm_source_dir, filename + '.ctm')
|
||||
|
||||
if not os.path.isfile(ctm_filepath):
|
||||
logging.info(f"Skipping {filename}.wav as there is no corresponding CTM file")
|
||||
src_i += 1
|
||||
continue
|
||||
|
||||
with open(ctm_filepath, 'r') as ctm_file:
|
||||
lines = ctm_file.readlines()
|
||||
|
||||
# One-word samples should be filtered out.
|
||||
if len(lines) <= 1:
|
||||
src_i += 1
|
||||
continue
|
||||
|
||||
words = []
|
||||
end_times = []
|
||||
i = 0
|
||||
prev_end = 0
|
||||
for i in range(len(lines)):
|
||||
ctm = lines[i].split(' ')
|
||||
start, end, speaker_id = get_seg_info_from_ctm_line(ctm_list=ctm, output_precision=output_precision)
|
||||
interval = start - prev_end
|
||||
|
||||
if (i == 0 and interval > 0) or (i > 0 and interval > silence_dur_threshold):
|
||||
words.append("")
|
||||
end_times.append(start)
|
||||
elif i > 0:
|
||||
end_times[-1] = start
|
||||
|
||||
words.append(ctm[4])
|
||||
end_times.append(end)
|
||||
|
||||
i += 1
|
||||
prev_end = end
|
||||
|
||||
# append last end
|
||||
if f['duration'] > prev_end:
|
||||
words.append("")
|
||||
end_times.append(f['duration'])
|
||||
|
||||
# build target manifest entry
|
||||
target_manifest.append(
|
||||
{
|
||||
'audio_filepath': f['audio_filepath'],
|
||||
'duration': f['duration'],
|
||||
'text': f['text'],
|
||||
'words': words,
|
||||
'alignments': end_times,
|
||||
'speaker_id': speaker_id,
|
||||
}
|
||||
)
|
||||
|
||||
src_i += 1
|
||||
tgt_i += 1
|
||||
|
||||
logging.info(f"Writing output manifest file to {output_manifest_filepath}")
|
||||
write_manifest(output_manifest_filepath, target_manifest)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Create a combined manifest file including word alignments and speaker IDs
|
||||
"""
|
||||
input_manifest_filepath = args.input_manifest_filepath
|
||||
base_alignment_path = args.base_alignment_path
|
||||
output_manifest_filepath = args.output_manifest_filepath
|
||||
ctm_output_directory = args.ctm_output_directory
|
||||
libri_dataset_split = args.libri_dataset_split
|
||||
use_ctm_alignment_source = args.use_ctm_alignment_source
|
||||
output_precision = args.output_precision
|
||||
|
||||
# Case 1: args.base_alignment_path is containing the ctm files
|
||||
if use_ctm_alignment_source:
|
||||
ctm_source_dir = args.base_alignment_path
|
||||
# Case 2: args.base_alignment_path is containing *.lab style alignments for the dataset
|
||||
else:
|
||||
create_librispeech_ctm_alignments(
|
||||
input_manifest_filepath, base_alignment_path, ctm_output_directory, libri_dataset_split
|
||||
)
|
||||
ctm_source_dir = ctm_output_directory
|
||||
|
||||
create_manifest_with_alignments(
|
||||
input_manifest_filepath,
|
||||
ctm_source_dir,
|
||||
output_manifest_filepath,
|
||||
data_format_style=args.data_format_style,
|
||||
silence_dur_threshold=args.silence_dur_threshold,
|
||||
output_precision=output_precision,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""
|
||||
This script creates a manifest file to be used for generating synthetic
|
||||
multispeaker audio sessions. The script takes in the default manifest file
|
||||
for a LibriSpeech dataset and corresponding word alignments and produces
|
||||
a combined manifest file that contains word alignments and speaker IDs
|
||||
per example. It can also be used to produce a manifest file for a different
|
||||
dataset if alignments are passed in CTM files.
|
||||
|
||||
The alignments are obtained from: https://github.com/CorentinJ/librispeech-alignments
|
||||
|
||||
Args:
|
||||
input_manifest_filepath (str): Path to input manifest file
|
||||
base_alignment_path (str): Path to the base directory for the LibriSpeech alignment dataset
|
||||
(specifically to the LibriSpeech-Alignments directory containing
|
||||
both the LibriSpeech folder as well as the unaligned.txt file)
|
||||
or to a directory containing the requisite CTM files
|
||||
output_manifest_filepath (str): Path to output manifest file
|
||||
ctm_output_directory (str): Path to output CTM directory (only used for LibriSpeech)
|
||||
libri_dataset_split (str): Which dataset split to create a combined manifest file for
|
||||
use_ctm_alignment_source (bool): If true, base_alignment_path points to a directory containing ctm files
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="LibriSpeech Alignment Manifest Creator")
|
||||
parser.add_argument("--input_manifest_filepath", help="path to input manifest file", type=str, required=True)
|
||||
parser.add_argument("--base_alignment_path", help="path to alignments (LibriSpeech)", type=str, required=False)
|
||||
parser.add_argument("--output_manifest_filepath", help="path to output manifest file", type=str, required=True)
|
||||
parser.add_argument(
|
||||
"--ctm_output_directory",
|
||||
help="path to output ctm directory for LibriSpeech (or to input CTM directory)",
|
||||
type=str,
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--libri_dataset_split",
|
||||
help="which test/dev/training set to create a manifest for (only used for LibriSpeech)",
|
||||
type=str,
|
||||
required=False,
|
||||
default="",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use_ctm_alignment_source",
|
||||
help="if true, base_alignment_path points to a directory containing ctm files",
|
||||
action='store_true',
|
||||
required=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--data_format_style",
|
||||
help="Use specific format for speaker IDs and utterance IDs. e.g. 'voxceleb', 'librispeech', 'swbd'",
|
||||
default="",
|
||||
type=str,
|
||||
required=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output_precision", help="precision for output alignments", type=int, required=False, default=3
|
||||
)
|
||||
parser.add_argument(
|
||||
"--silence_dur_threshold", help="threshold for inserting silence", type=float, required=False, default=0.1
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,205 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script creates a manifest file for diarization training. If you specify `pairwise_rttm_output_folder`, the script generates
|
||||
a two-speaker subset of the original RTTM files. For example, an RTTM file with 4 speakers will obtain 6 different pairs and
|
||||
6 RTTM files with two speakers in each RTTM file.
|
||||
|
||||
Args:
|
||||
--input_manifest_path: input json file name
|
||||
--output_manifest_path: output manifest_file name
|
||||
--pairwise_rttm_output_folder: Save two-speaker pair RTTM files
|
||||
--window: Window length for segmentation
|
||||
--shift: Shift length for segmentation
|
||||
--decimals: Rounding decimals
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import itertools
|
||||
import os
|
||||
import random
|
||||
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import (
|
||||
get_input_manifest_dict,
|
||||
get_subsegment_dict,
|
||||
rreplace,
|
||||
write_truncated_subsegments,
|
||||
)
|
||||
from nemo.collections.asr.parts.utils.speaker_utils import (
|
||||
audio_rttm_map,
|
||||
rttm_to_labels,
|
||||
segments_manifest_to_subsegments_manifest,
|
||||
write_rttm2manifest,
|
||||
)
|
||||
from nemo.utils import logging
|
||||
|
||||
random.seed(42)
|
||||
|
||||
|
||||
def labels_to_rttmfile(labels, uniq_id, filename, out_rttm_dir):
|
||||
"""
|
||||
Write rttm file with uniq_id name in out_rttm_dir with time_stamps in labels
|
||||
"""
|
||||
filename = os.path.join(out_rttm_dir, filename + '.rttm')
|
||||
with open(filename, 'w') as f:
|
||||
for line in labels:
|
||||
line = line.strip()
|
||||
start, end, speaker = line.split()
|
||||
duration = float(end) - float(start)
|
||||
start = float(start)
|
||||
log = 'SPEAKER {} 1 {:.3f} {:.3f} <NA> <NA> {} <NA> <NA>\n'.format(uniq_id, start, duration, speaker)
|
||||
f.write(log)
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def split_into_pairwise_rttm(audio_rttm_map, input_manifest_path, output_dir):
|
||||
"""
|
||||
Create pairwise RTTM files and save it to `output_dir`. This function picks two speakers from the original RTTM files
|
||||
then saves the two-speaker subset of RTTM to `output_dir`.
|
||||
|
||||
Args:
|
||||
audio_rttm_map (dict):
|
||||
A dictionary with keys of uniq id, which is being used to map audio files and corresponding rttm files
|
||||
input_manifest_path (str):
|
||||
Path of the input manifest file.
|
||||
output_dir (str):
|
||||
Path to the directory where the new RTTM files are saved.
|
||||
"""
|
||||
input_manifest_dict = get_input_manifest_dict(input_manifest_path)
|
||||
rttmlist = []
|
||||
rttm_split_manifest_dict = {}
|
||||
split_audio_rttm_map = {}
|
||||
logging.info("Creating split RTTM files.")
|
||||
for uniq_id, line in tqdm(input_manifest_dict.items(), total=len(input_manifest_dict)):
|
||||
audiopath = line['audio_filepath']
|
||||
num_speakers = line['num_speakers']
|
||||
rttm_filepath = line['rttm_filepath']
|
||||
|
||||
rttm = rttm_to_labels(rttm_filepath)
|
||||
speakers = []
|
||||
j = 0
|
||||
while len(speakers) < num_speakers:
|
||||
if rttm[j].split(' ')[2] not in speakers:
|
||||
speakers.append(rttm[j].split(' ')[2])
|
||||
j += 1
|
||||
base_fn = audiopath.split('/')[-1].replace('.wav', '')
|
||||
for pair in itertools.combinations(speakers, 2):
|
||||
i, target_rttm = 0, []
|
||||
while i < len(rttm):
|
||||
entry = rttm[i]
|
||||
sp_id = entry.split(' ')[2]
|
||||
if sp_id in pair:
|
||||
target_rttm.append(entry)
|
||||
i += 1
|
||||
|
||||
pair_string = f".{pair[0]}_{pair[1]}"
|
||||
uniq_id_pair = uniq_id + pair_string
|
||||
filename = base_fn + pair_string
|
||||
labels_to_rttmfile(target_rttm, base_fn, filename, output_dir)
|
||||
rttm_path = output_dir + filename + ".rttm"
|
||||
rttmlist.append(rttm_path)
|
||||
line_mod = copy.deepcopy(line)
|
||||
line_mod['rttm_filepath'] = rttm_path
|
||||
meta = copy.deepcopy(audio_rttm_map[uniq_id])
|
||||
meta['rttm_filepath'] = rttm_path
|
||||
rttm_split_manifest_dict[uniq_id_pair] = line_mod
|
||||
split_audio_rttm_map[uniq_id_pair] = meta
|
||||
|
||||
return rttm_split_manifest_dict, split_audio_rttm_map
|
||||
|
||||
|
||||
def main(input_manifest_path, output_manifest_path, pairwise_rttm_output_folder, window, shift, step_count, decimals):
|
||||
|
||||
if '.json' not in input_manifest_path:
|
||||
raise ValueError("input_manifest_path file should be .json file format")
|
||||
if output_manifest_path and '.json' not in output_manifest_path:
|
||||
raise ValueError("output_manifest_path file should be .json file format")
|
||||
elif not output_manifest_path:
|
||||
output_manifest_path = rreplace(input_manifest_path, '.json', f'.{step_count}seg.json')
|
||||
|
||||
if pairwise_rttm_output_folder is not None:
|
||||
if not pairwise_rttm_output_folder.endswith('/'):
|
||||
pairwise_rttm_output_folder = f"{pairwise_rttm_output_folder}/"
|
||||
org_audio_rttm_map = audio_rttm_map(input_manifest_path)
|
||||
input_manifest_dict, AUDIO_RTTM_MAP = split_into_pairwise_rttm(
|
||||
audio_rttm_map=org_audio_rttm_map,
|
||||
input_manifest_path=input_manifest_path,
|
||||
output_dir=pairwise_rttm_output_folder,
|
||||
)
|
||||
else:
|
||||
input_manifest_dict = get_input_manifest_dict(input_manifest_path)
|
||||
AUDIO_RTTM_MAP = audio_rttm_map(input_manifest_path)
|
||||
|
||||
segment_manifest_path = rreplace(input_manifest_path, '.json', '_seg.json')
|
||||
subsegment_manifest_path = rreplace(input_manifest_path, '.json', '_subseg.json')
|
||||
|
||||
# todo: do we need to expose this?
|
||||
min_subsegment_duration = 0.05
|
||||
step_count = int(step_count)
|
||||
|
||||
segments_manifest_file = write_rttm2manifest(AUDIO_RTTM_MAP, segment_manifest_path, decimals)
|
||||
subsegments_manifest_file = subsegment_manifest_path
|
||||
|
||||
logging.info("Creating subsegments.")
|
||||
segments_manifest_to_subsegments_manifest(
|
||||
segments_manifest_file=segments_manifest_file,
|
||||
subsegments_manifest_file=subsegments_manifest_file,
|
||||
window=window,
|
||||
shift=shift,
|
||||
min_subsegment_duration=min_subsegment_duration,
|
||||
include_uniq_id=True,
|
||||
)
|
||||
subsegments_dict = get_subsegment_dict(subsegments_manifest_file, window, shift, decimals)
|
||||
write_truncated_subsegments(input_manifest_dict, subsegments_dict, output_manifest_path, step_count, decimals)
|
||||
os.remove(segment_manifest_path)
|
||||
os.remove(subsegment_manifest_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input_manifest_path", help="input json file name", type=str, required=True)
|
||||
parser.add_argument(
|
||||
"--output_manifest_path", help="output manifest_file name", type=str, default=None, required=False
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pairwise_rttm_output_folder",
|
||||
help="Save two-speaker pair RTTM files",
|
||||
type=str,
|
||||
default=None,
|
||||
required=False,
|
||||
)
|
||||
parser.add_argument("--window", help="Window length for segmentation", type=float, required=True)
|
||||
parser.add_argument("--shift", help="Shift length for segmentation", type=float, required=True)
|
||||
parser.add_argument("--decimals", help="Rounding decimals", type=int, default=3, required=False)
|
||||
parser.add_argument(
|
||||
"--step_count",
|
||||
help="Number of the unit segments you want to create per utterance",
|
||||
required=True,
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
main(
|
||||
args.input_manifest_path,
|
||||
args.output_manifest_path,
|
||||
args.pairwise_rttm_output_folder,
|
||||
args.window,
|
||||
args.shift,
|
||||
args.step_count,
|
||||
args.decimals,
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import multiprocessing as mp
|
||||
from itertools import repeat
|
||||
from pathlib import Path
|
||||
|
||||
import librosa
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest, write_manifest
|
||||
from nemo.collections.asr.parts.utils.vad_utils import get_frame_labels, load_speech_segments_from_rttm
|
||||
|
||||
"""
|
||||
This script generates a manifest file for synthetic data generated using the NeMo multispeaker speech data simulator.
|
||||
The audio created from the simulator can be used to train a VAD model using the manifest file contains the following fields:
|
||||
The manifest file contains the following fields:
|
||||
|
||||
audio_filepath (str): Path to audio file.
|
||||
offset (float): Offset in seconds for the start of the audio file.
|
||||
duration (float): Duration in seconds for the audio file.
|
||||
text (str): Transcription of the audio file.
|
||||
label (list): List of frame labels for the audio file.
|
||||
orig_sample_rate (int): Original sample rate of the audio file.
|
||||
vad_frame_unit_secs (float): Duration in seconds for each frame label.
|
||||
|
||||
Usage:
|
||||
python build_synthetic_vad_manifest.py \
|
||||
--input_dir /path/to/synthetic/data \
|
||||
--frame_length 0.04 \
|
||||
--output_file /path/to/output/manifest.json
|
||||
"""
|
||||
|
||||
|
||||
def generate_manifest_entry(inputs):
|
||||
"""
|
||||
Generates a manifest entry for a single audio file.
|
||||
This function is parallelized using multiprocessing.Pool.
|
||||
|
||||
Args:
|
||||
inputs (tuple): Tuple containing audio file path and frame length in seconds.
|
||||
inputs[0]:
|
||||
audio_filepath (str): Path to audio file.
|
||||
inputs[1]:
|
||||
vad_frame_unit_secs (float): Duration in seconds for each frame label.
|
||||
|
||||
Returns:
|
||||
entry (dict): Dictionary containing manifest entry.
|
||||
"""
|
||||
audio_filepath, vad_frame_unit_secs = inputs
|
||||
audio_filepath = Path(audio_filepath)
|
||||
y, sr = librosa.load(str(audio_filepath))
|
||||
dur = librosa.get_duration(y=y, sr=sr)
|
||||
|
||||
manifest_path = audio_filepath.parent / Path(f"{audio_filepath.stem}.json")
|
||||
audio_manifest = read_manifest(manifest_path)
|
||||
text = " ".join([x["text"] for x in audio_manifest])
|
||||
|
||||
rttm_path = audio_filepath.parent / Path(f"{audio_filepath.stem}.rttm")
|
||||
segments = load_speech_segments_from_rttm(rttm_path)
|
||||
labels = get_frame_labels(segments, vad_frame_unit_secs, 0.0, dur)
|
||||
|
||||
entry = {
|
||||
"audio_filepath": str(audio_filepath.absolute()),
|
||||
"offset": 0.0,
|
||||
"duration": dur,
|
||||
"text": text,
|
||||
"label": labels,
|
||||
"orig_sample_rate": sr,
|
||||
"vad_frame_unit_secs": vad_frame_unit_secs,
|
||||
}
|
||||
return entry
|
||||
|
||||
|
||||
def main(args):
|
||||
wav_list = list(Path(args.input_dir).glob("*.wav"))
|
||||
print(f"Found {len(wav_list)} in directory: {args.input_dir}")
|
||||
|
||||
inputs = zip(wav_list, repeat(args.frame_length))
|
||||
with mp.Pool(processes=mp.cpu_count()) as pool:
|
||||
manifest_data = list(tqdm(pool.imap(generate_manifest_entry, inputs), total=len(wav_list)))
|
||||
|
||||
write_manifest(args.output_file, manifest_data)
|
||||
print(f"Manifest saved to: {args.output_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input_dir", default=None, help="Path to directory containing synthetic data")
|
||||
parser.add_argument(
|
||||
"-l", "--frame_length", default=0.04, type=float, help="Duration in seconds for each frame label"
|
||||
)
|
||||
parser.add_argument("-o", "--output_file", default=None, help="Path to output manifest file")
|
||||
|
||||
args = parser.parse_args()
|
||||
main(args)
|
||||
@@ -0,0 +1,242 @@
|
||||
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
|
||||
from nemo.collections.asr.metrics.der import evaluate_der
|
||||
from nemo.collections.asr.parts.utils.diarization_utils import OfflineDiarWithASR
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_file
|
||||
from nemo.collections.asr.parts.utils.speaker_utils import (
|
||||
get_uniqname_from_filepath,
|
||||
labels_to_supervisions,
|
||||
rttm_to_labels,
|
||||
)
|
||||
|
||||
|
||||
"""
|
||||
Evaluation script for diarization with ASR.
|
||||
Calculates Diarization Error Rate (DER) with RTTM files and WER and cpWER with CTM files.
|
||||
In the output ctm_eval.csv file in the output folder,
|
||||
session-level DER, WER, cpWER and speaker counting accuracies are evaluated.
|
||||
|
||||
- Evaluation mode
|
||||
|
||||
diar_eval_mode == "full":
|
||||
DIHARD challenge style evaluation, the most strict way of evaluating diarization
|
||||
(collar, ignore_overlap) = (0.0, False)
|
||||
diar_eval_mode == "fair":
|
||||
Evaluation setup used in VoxSRC challenge
|
||||
(collar, ignore_overlap) = (0.25, False)
|
||||
diar_eval_mode == "forgiving":
|
||||
Traditional evaluation setup
|
||||
(collar, ignore_overlap) = (0.25, True)
|
||||
diar_eval_mode == "all":
|
||||
Compute all three modes (default)
|
||||
|
||||
|
||||
Use CTM files to calculate WER and cpWER
|
||||
```
|
||||
python eval_diar_with_asr.py \
|
||||
--hyp_rttm_list="/path/to/hypothesis_rttm_filepaths.list" \
|
||||
--ref_rttm_list="/path/to/reference_rttm_filepaths.list" \
|
||||
--hyp_ctm_list="/path/to/hypothesis_ctm_filepaths.list" \
|
||||
--ref_ctm_list="/path/to/reference_ctm_filepaths.list" \
|
||||
--root_path="/path/to/output/directory"
|
||||
```
|
||||
|
||||
Use .json files to calculate WER and cpWER
|
||||
```
|
||||
python eval_diar_with_asr.py \
|
||||
--hyp_rttm_list="/path/to/hypothesis_rttm_filepaths.list" \
|
||||
--ref_rttm_list="/path/to/reference_rttm_filepaths.list" \
|
||||
--hyp_json_list="/path/to/hypothesis_json_filepaths.list" \
|
||||
--ref_ctm_list="/path/to/reference_ctm_filepaths.list" \
|
||||
--root_path="/path/to/output/directory"
|
||||
```
|
||||
|
||||
Only use RTTMs to calculate DER
|
||||
```
|
||||
python eval_diar_with_asr.py \
|
||||
--hyp_rttm_list="/path/to/hypothesis_rttm_filepaths.list" \
|
||||
--ref_rttm_list="/path/to/reference_rttm_filepaths.list" \
|
||||
--root_path="/path/to/output/directory"
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def get_supervisions_from_rttms(rttm_file_path_list):
|
||||
"""Generate diarization annotation objects from a list of RTTM files.
|
||||
|
||||
Each entry in the returned list is ``[uniq_id, list[SupervisionSegment]]``.
|
||||
"""
|
||||
annotation_obj_list = []
|
||||
for rttm_file in rttm_file_path_list:
|
||||
rttm_file = rttm_file.strip()
|
||||
if rttm_file is not None and os.path.exists(rttm_file):
|
||||
uniq_id = get_uniqname_from_filepath(rttm_file)
|
||||
ref_labels = rttm_to_labels(rttm_file)
|
||||
reference = labels_to_supervisions(ref_labels, uniq_name=uniq_id)
|
||||
annotation_obj_list.append([uniq_id, reference])
|
||||
return annotation_obj_list
|
||||
|
||||
|
||||
def make_meta_dict(hyp_rttm_list, ref_rttm_list):
|
||||
"""Create a temporary `audio_rttm_map_dict` for evaluation"""
|
||||
meta_dict = {}
|
||||
for k, rttm_file in enumerate(ref_rttm_list):
|
||||
uniq_id = get_uniqname_from_filepath(rttm_file)
|
||||
meta_dict[uniq_id] = {"rttm_filepath": rttm_file.strip()}
|
||||
if hyp_rttm_list is not None:
|
||||
hyp_rttm_file = hyp_rttm_list[k]
|
||||
meta_dict[uniq_id].update({"hyp_rttm_filepath": hyp_rttm_file.strip()})
|
||||
return meta_dict
|
||||
|
||||
|
||||
def make_trans_info_dict(hyp_json_list_path):
|
||||
"""Create `trans_info_dict` from the `.json` files"""
|
||||
trans_info_dict = {}
|
||||
for json_file in hyp_json_list_path:
|
||||
json_file = json_file.strip()
|
||||
with open(json_file) as jsf:
|
||||
json_data = json.load(jsf)
|
||||
uniq_id = get_uniqname_from_filepath(json_file)
|
||||
trans_info_dict[uniq_id] = json_data
|
||||
return trans_info_dict
|
||||
|
||||
|
||||
def read_file_path(list_path):
|
||||
"""Read file path and strip to remove line change symbol"""
|
||||
return sorted([x.strip() for x in read_file(list_path)])
|
||||
|
||||
|
||||
def main(
|
||||
hyp_rttm_list_path: str,
|
||||
ref_rttm_list_path: str,
|
||||
hyp_ctm_list_path: str,
|
||||
ref_ctm_list_path: str,
|
||||
hyp_json_list_path: str,
|
||||
diar_eval_mode: str = "all",
|
||||
root_path: str = "./",
|
||||
):
|
||||
|
||||
# Read filepath list files
|
||||
hyp_rttm_list = read_file_path(hyp_rttm_list_path) if hyp_rttm_list_path else None
|
||||
ref_rttm_list = read_file_path(ref_rttm_list_path) if ref_rttm_list_path else None
|
||||
hyp_ctm_list = read_file_path(hyp_ctm_list_path) if hyp_ctm_list_path else None
|
||||
ref_ctm_list = read_file_path(ref_ctm_list_path) if ref_ctm_list_path else None
|
||||
hyp_json_list = read_file_path(hyp_json_list_path) if hyp_json_list_path else None
|
||||
|
||||
audio_rttm_map_dict = make_meta_dict(hyp_rttm_list, ref_rttm_list)
|
||||
|
||||
trans_info_dict = make_trans_info_dict(hyp_json_list) if hyp_json_list else None
|
||||
|
||||
all_hypothesis = get_supervisions_from_rttms(hyp_rttm_list)
|
||||
all_reference = get_supervisions_from_rttms(ref_rttm_list)
|
||||
|
||||
diar_score = evaluate_der(
|
||||
audio_rttm_map_dict=audio_rttm_map_dict,
|
||||
all_reference=all_reference,
|
||||
all_hypothesis=all_hypothesis,
|
||||
diar_eval_mode=diar_eval_mode,
|
||||
)
|
||||
|
||||
# Get session-level diarization error rate and speaker counting error
|
||||
der_results = OfflineDiarWithASR.gather_eval_results(
|
||||
diar_score=diar_score,
|
||||
audio_rttm_map_dict=audio_rttm_map_dict,
|
||||
trans_info_dict=trans_info_dict,
|
||||
root_path=root_path,
|
||||
)
|
||||
|
||||
if ref_ctm_list is not None:
|
||||
# Calculate WER and cpWER if reference CTM files exist
|
||||
if hyp_ctm_list is not None:
|
||||
wer_results = OfflineDiarWithASR.evaluate(
|
||||
audio_file_list=hyp_rttm_list,
|
||||
hyp_trans_info_dict=None,
|
||||
hyp_ctm_file_list=hyp_ctm_list,
|
||||
ref_ctm_file_list=ref_ctm_list,
|
||||
)
|
||||
elif hyp_json_list is not None:
|
||||
wer_results = OfflineDiarWithASR.evaluate(
|
||||
audio_file_list=hyp_rttm_list,
|
||||
hyp_trans_info_dict=trans_info_dict,
|
||||
hyp_ctm_file_list=None,
|
||||
ref_ctm_file_list=ref_ctm_list,
|
||||
)
|
||||
else:
|
||||
raise ValueError("Hypothesis information is not provided in the correct format.")
|
||||
else:
|
||||
wer_results = {}
|
||||
|
||||
# Print average DER, WER and cpWER
|
||||
OfflineDiarWithASR.print_errors(der_results=der_results, wer_results=wer_results)
|
||||
|
||||
# Save detailed session-level evaluation results in `root_path`.
|
||||
OfflineDiarWithASR.write_session_level_result_in_csv(
|
||||
der_results=der_results,
|
||||
wer_results=wer_results,
|
||||
root_path=root_path,
|
||||
csv_columns=OfflineDiarWithASR.get_csv_columns(),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--hyp_rttm_list", help="path to the filelist of hypothesis RTTM files", type=str, required=True, default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ref_rttm_list", help="path to the filelist of reference RTTM files", type=str, required=True, default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hyp_ctm_list", help="path to the filelist of hypothesis CTM files", type=str, required=False, default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ref_ctm_list", help="path to the filelist of reference CTM files", type=str, required=False, default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"--hyp_json_list",
|
||||
help="(Optional) path to the filelist of hypothesis JSON files",
|
||||
type=str,
|
||||
required=False,
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--diar_eval_mode",
|
||||
help='evaluation mode: "all", "full", "fair", "forgiving"',
|
||||
type=str,
|
||||
required=False,
|
||||
default="all",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--root_path", help='directory for saving result files', type=str, required=False, default="./"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
main(
|
||||
args.hyp_rttm_list,
|
||||
args.ref_rttm_list,
|
||||
args.hyp_ctm_list,
|
||||
args.ref_ctm_list,
|
||||
args.hyp_json_list,
|
||||
args.diar_eval_mode,
|
||||
args.root_path,
|
||||
)
|
||||
@@ -0,0 +1,259 @@
|
||||
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
"""
|
||||
This script converts a filelist file where each line contains
|
||||
<absolute path of wav file> to a manifest json file.
|
||||
Optionally post processes the manifest file to create dev and train split for speaker embedding
|
||||
training, also optionally segment an audio file in to segments of random DURATIONS and create those
|
||||
wav files in CWD.
|
||||
|
||||
Args:
|
||||
--filelist: path to file containing list of audio files
|
||||
--manifest(optional): if you already have manifest file, but would like to process it for creating
|
||||
segments and splitting then use manifest ignoring filelist
|
||||
--id: index of speaker label in filename present in filelist file that is separated by '/'
|
||||
--out: output manifest file name
|
||||
--split: if you would want to split the manifest file for training purposes
|
||||
you may not need this for test set. output file names is <out>_<train/dev>.json, defaults to False
|
||||
--create_segments: if you would want to segment each manifest line to segments of [1,2,3,4] sec or less
|
||||
you may not need this for test set, defaults to False
|
||||
--min_spkrs_count: min number of samples per speaker to consider and ignore otherwise, defaults to 0 (all speakers)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
|
||||
import librosa as l
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from sklearn.model_selection import StratifiedShuffleSplit
|
||||
from tqdm.contrib.concurrent import process_map
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest
|
||||
|
||||
random.seed(42)
|
||||
|
||||
DURATIONS = sorted([3], reverse=True)
|
||||
MIN_ENERGY = 0.01
|
||||
CWD = os.getcwd()
|
||||
|
||||
|
||||
def _load_sox():
|
||||
try:
|
||||
import sox
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Optional dependency 'sox' is required by this script. Install it with: pip install sox"
|
||||
) from None
|
||||
|
||||
return sox
|
||||
|
||||
|
||||
def filter_manifest_line(manifest_line):
|
||||
split_manifest = []
|
||||
audio_path = manifest_line['audio_filepath']
|
||||
start = manifest_line.get('offset', 0)
|
||||
dur = manifest_line['duration']
|
||||
label = manifest_line['label']
|
||||
endname = os.path.splitext(audio_path.split(label, 1)[-1])[0]
|
||||
to_path = os.path.join(CWD, 'segments', label)
|
||||
to_path = os.path.join(to_path, endname[1:])
|
||||
os.makedirs(os.path.dirname(to_path), exist_ok=True)
|
||||
|
||||
if dur >= min(DURATIONS):
|
||||
signal, sr = sf.read(audio_path)
|
||||
remaining_dur = dur - start
|
||||
|
||||
segments = DURATIONS.copy()
|
||||
mode = int(remaining_dur // sum(DURATIONS))
|
||||
rem = remaining_dur % sum(DURATIONS)
|
||||
segments = mode * segments
|
||||
|
||||
for val in DURATIONS:
|
||||
if rem >= val:
|
||||
segments.append(val)
|
||||
rem = rem - val
|
||||
|
||||
for temp_dur in segments:
|
||||
segment_audio = signal[int(start * sr) : int(start * sr + temp_dur * sr)]
|
||||
if l.feature.rms(y=segment_audio).mean() > MIN_ENERGY:
|
||||
final_string = '_' + str(start) + '_' + str(temp_dur)
|
||||
final_string = final_string.replace('.', '-')
|
||||
to_file = to_path + final_string + '.wav'
|
||||
|
||||
c_start = int(float(start * sr))
|
||||
c_end = c_start + int(float(temp_dur * sr))
|
||||
segment = signal[c_start:c_end]
|
||||
sf.write(to_file, segment, sr)
|
||||
|
||||
meta = manifest_line.copy()
|
||||
meta['audio_filepath'] = to_file
|
||||
meta['offset'] = 0
|
||||
meta['duration'] = temp_dur
|
||||
split_manifest.append(meta)
|
||||
|
||||
start = start + temp_dur
|
||||
|
||||
return split_manifest
|
||||
|
||||
|
||||
def count_and_consider_only(speakers, lines, min_count=10):
|
||||
"""
|
||||
consider speakers only if samples per speaker is at least min_count
|
||||
"""
|
||||
uniq_speakers, indices, counts = np.unique(speakers, return_index=True, return_counts=True)
|
||||
print("speaker count before filtering minimum number of speaker counts: ", len(uniq_speakers))
|
||||
required_speakers = {}
|
||||
for idx, count in enumerate(counts):
|
||||
if count >= min_count:
|
||||
required_speakers[uniq_speakers[idx]] = count
|
||||
|
||||
print("speaker count after filtering minimum number of speaker counts: ", len(required_speakers))
|
||||
required_lines = []
|
||||
speakers_only = []
|
||||
for idx, speaker in enumerate(speakers):
|
||||
if speaker in required_speakers:
|
||||
required_lines.append(lines[idx])
|
||||
speakers_only.append(speaker)
|
||||
|
||||
return speakers_only, required_lines
|
||||
|
||||
|
||||
def write_file(name, lines, idx):
|
||||
with open(name, 'w', encoding='utf-8') as fout:
|
||||
for i in idx:
|
||||
dic = lines[i]
|
||||
json.dump(dic, fout)
|
||||
fout.write('\n')
|
||||
print("wrote", name)
|
||||
|
||||
|
||||
def read_file(filelist, id=-1):
|
||||
json_lines = []
|
||||
with open(filelist, 'r') as fo:
|
||||
lines = fo.readlines()
|
||||
lines = sorted(lines)
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
speaker = line.split('/')[id]
|
||||
speaker = list(speaker)
|
||||
speaker = ''.join(speaker)
|
||||
meta = {"audio_filepath": line, "offset": 0, "duration": None, "label": speaker}
|
||||
json_lines.append(meta)
|
||||
return json_lines
|
||||
|
||||
|
||||
def get_duration(json_line):
|
||||
dur = json_line['duration']
|
||||
if dur is None:
|
||||
sox = _load_sox()
|
||||
wav_path = json_line['audio_filepath']
|
||||
json_line['duration'] = sox.file_info.duration(wav_path)
|
||||
return json_line
|
||||
|
||||
|
||||
def get_labels(lines):
|
||||
labels = []
|
||||
for line in lines:
|
||||
label = line['label']
|
||||
labels.append(label)
|
||||
return labels
|
||||
|
||||
|
||||
def main(filelist, manifest, id, out, split=False, create_segments=False, min_count=10):
|
||||
if os.path.exists(out):
|
||||
os.remove(out)
|
||||
if filelist:
|
||||
lines = read_file(filelist=filelist, id=id)
|
||||
lines = process_map(get_duration, lines, chunksize=100)
|
||||
out_file = os.path.splitext(filelist)[0] + '_manifest.json'
|
||||
write_file(out_file, lines, range(len(lines)))
|
||||
else:
|
||||
lines = read_manifest(manifest)
|
||||
|
||||
lines = process_map(get_duration, lines, chunksize=100)
|
||||
|
||||
if create_segments:
|
||||
print(f"creating and writing segments to {CWD}")
|
||||
lines = process_map(filter_manifest_line, lines, chunksize=100)
|
||||
temp = []
|
||||
for line in lines:
|
||||
temp.extend(line)
|
||||
del lines
|
||||
lines = temp
|
||||
|
||||
speakers = [x['label'] for x in lines]
|
||||
|
||||
if min_count:
|
||||
speakers, lines = count_and_consider_only(speakers, lines, abs(min_count))
|
||||
|
||||
write_file(out, lines, range(len(lines)))
|
||||
path = os.path.dirname(out)
|
||||
if split:
|
||||
speakers = [x['label'] for x in lines]
|
||||
sss = StratifiedShuffleSplit(n_splits=1, test_size=0.1, random_state=42)
|
||||
for train_idx, test_idx in sss.split(speakers, speakers):
|
||||
print("number of train samples after split: ", len(train_idx))
|
||||
|
||||
out = os.path.join(path, 'train.json')
|
||||
write_file(out, lines, train_idx)
|
||||
out = os.path.join(path, 'dev.json')
|
||||
write_file(out, lines, test_idx)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--filelist", help="path to filelist file", type=str, required=False, default=None)
|
||||
parser.add_argument("--manifest", help="manifest file name", type=str, required=False, default=None)
|
||||
parser.add_argument(
|
||||
"--id",
|
||||
help="field num seperated by '/' to be considered as speaker label from filelist file, can be ignored if manifest file is already provided with labels",
|
||||
type=int,
|
||||
required=False,
|
||||
default=None,
|
||||
)
|
||||
parser.add_argument("--out", help="manifest_file name", type=str, required=True)
|
||||
parser.add_argument(
|
||||
"--split",
|
||||
help="bool if you would want to split the manifest file for training purposes",
|
||||
required=False,
|
||||
action='store_true',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--create_segments",
|
||||
help="bool if you would want to segment each manifest line to segments of 4 sec or less",
|
||||
required=False,
|
||||
action='store_true',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min_spkrs_count",
|
||||
default=0,
|
||||
type=int,
|
||||
help="min number of samples per speaker to consider and ignore otherwise",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
main(
|
||||
args.filelist,
|
||||
args.manifest,
|
||||
args.id,
|
||||
args.out,
|
||||
args.split,
|
||||
args.create_segments,
|
||||
args.min_spkrs_count,
|
||||
)
|
||||
@@ -0,0 +1,301 @@
|
||||
# Copyright (c) 2023, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import multiprocessing
|
||||
import shutil
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from pprint import pprint
|
||||
from typing import Dict
|
||||
|
||||
import numpy as np
|
||||
from scipy.stats import expon
|
||||
from tqdm import tqdm
|
||||
|
||||
from nemo.collections.asr.parts.utils.vad_utils import (
|
||||
get_nonspeech_segments,
|
||||
load_speech_overlap_segments_from_rttm,
|
||||
plot_sample_from_rttm,
|
||||
)
|
||||
from nemo.utils.dependency import import_optional_dependency
|
||||
|
||||
"""
|
||||
This script analyzes multi-speaker speech dataset and generates statistics.
|
||||
The input directory </path/to/rttm_and_wav_directory> is required to contain the following files:
|
||||
- rttm files (*.rttm)
|
||||
- wav files (*.wav)
|
||||
|
||||
Usage:
|
||||
python <NEMO_ROOT>/scripts/speaker_tasks/multispeaker_data_analysis.py \
|
||||
</path/to/rttm_and_wav_directory> \
|
||||
--session_dur 20 \
|
||||
--silence_mean 0.2 \
|
||||
--silence_var 100 \
|
||||
--overlap_mean 0.15 \
|
||||
--overlap_var 50 \
|
||||
--num_workers 8 \
|
||||
--num_samples 10 \
|
||||
--output_dir <path/to/output_directory>
|
||||
"""
|
||||
|
||||
|
||||
def process_sample(sess_dict: Dict) -> Dict:
|
||||
"""
|
||||
Process each synthetic sample
|
||||
|
||||
Args:
|
||||
sess_dict (dict): dictionary containing the following keys
|
||||
rttm_file (str): path to the rttm file
|
||||
session_dur (float): duration of the session (specified by argument)
|
||||
precise (bool): whether to measure the precise duration of the session using sox
|
||||
|
||||
Returns:
|
||||
results (dict): dictionary containing the following keys
|
||||
session_dur (float): duration of the session
|
||||
silence_len_list (list): list of silence durations of each silence occurrence
|
||||
silence_dur (float): total silence duration in a session
|
||||
silence_ratio (float): ratio of silence duration to session duration
|
||||
overlap_len_list (list): list of overlap durations of each overlap occurrence
|
||||
overlap_dur (float): total overlap duration
|
||||
overlap_ratio (float): ratio of overlap duration to speech (non-silence) duration
|
||||
"""
|
||||
|
||||
rttm_file = sess_dict["rttm_file"]
|
||||
session_dur = sess_dict["session_dur"]
|
||||
precise = sess_dict["precise"]
|
||||
if precise or session_dur is None:
|
||||
sox = import_optional_dependency("sox")
|
||||
wav_file = rttm_file.parent / Path(rttm_file.stem + ".wav")
|
||||
session_dur = sox.file_info.duration(str(wav_file))
|
||||
|
||||
speech_seg, overlap_seg = load_speech_overlap_segments_from_rttm(rttm_file)
|
||||
speech_dur = sum([sess_dict[1] - sess_dict[0] for sess_dict in speech_seg])
|
||||
|
||||
silence_seg = get_nonspeech_segments(speech_seg, session_dur)
|
||||
silence_len_list = [sess_dict[1] - sess_dict[0] for sess_dict in silence_seg]
|
||||
silence_dur = max(0, session_dur - speech_dur)
|
||||
silence_ratio = silence_dur / session_dur
|
||||
|
||||
overlap_len_list = [sess_dict[1] - sess_dict[0] for sess_dict in overlap_seg]
|
||||
overlap_dur = sum(overlap_len_list) if len(overlap_len_list) else 0
|
||||
overlap_ratio = overlap_dur / speech_dur
|
||||
|
||||
results = {
|
||||
"session_dur": session_dur,
|
||||
"silence_len_list": silence_len_list,
|
||||
"silence_dur": silence_dur,
|
||||
"silence_ratio": silence_ratio,
|
||||
"overlap_len_list": overlap_len_list,
|
||||
"overlap_dur": overlap_dur,
|
||||
"overlap_ratio": overlap_ratio,
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def run_multispeaker_data_analysis(
|
||||
input_dir,
|
||||
session_dur=None,
|
||||
silence_mean=None,
|
||||
silence_var=None,
|
||||
overlap_mean=None,
|
||||
overlap_var=None,
|
||||
precise=False,
|
||||
save_path=None,
|
||||
num_workers=1,
|
||||
) -> Dict:
|
||||
rttm_list = list(Path(input_dir).glob("*.rttm"))
|
||||
"""
|
||||
Analyze the multispeaker data and plot the distribution of silence and overlap durations.
|
||||
|
||||
Args:
|
||||
input_dir (str): path to the directory containing the rttm files
|
||||
session_dur (float): duration of the session (specified by argument)
|
||||
silence_mean (float): mean of the silence duration distribution
|
||||
silence_var (float): variance of the silence duration distribution
|
||||
overlap_mean (float): mean of the overlap duration distribution
|
||||
overlap_var (float): variance of the overlap duration distribution
|
||||
precise (bool): whether to measure the precise duration of the session using sox
|
||||
save_path (str): path to save the plots
|
||||
|
||||
Returns:
|
||||
stats (dict): dictionary containing the statistics of the analyzed data
|
||||
"""
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sns = import_optional_dependency("seaborn")
|
||||
|
||||
print(f"Found {len(rttm_list)} files to be processed")
|
||||
if len(rttm_list) == 0:
|
||||
raise ValueError(f"No rttm files found in {input_dir}")
|
||||
|
||||
silence_duration = 0.0
|
||||
total_duration = 0.0
|
||||
overlap_duration = 0.0
|
||||
|
||||
silence_ratio_all = []
|
||||
overlap_ratio_all = []
|
||||
silence_length_all = []
|
||||
overlap_length_all = []
|
||||
|
||||
queue = []
|
||||
for rttm_file in tqdm(rttm_list):
|
||||
queue.append(
|
||||
{
|
||||
"rttm_file": rttm_file,
|
||||
"session_dur": session_dur,
|
||||
"precise": precise,
|
||||
}
|
||||
)
|
||||
|
||||
if num_workers <= 1:
|
||||
results = [process_sample(sess_dict) for sess_dict in tqdm(queue)]
|
||||
else:
|
||||
with multiprocessing.Pool(processes=num_workers) as p:
|
||||
results = list(
|
||||
tqdm(
|
||||
p.imap(process_sample, queue),
|
||||
total=len(queue),
|
||||
desc='Processing',
|
||||
leave=True,
|
||||
)
|
||||
)
|
||||
|
||||
for item in results:
|
||||
total_duration += item["session_dur"]
|
||||
silence_duration += item["silence_dur"]
|
||||
overlap_duration += item["overlap_dur"]
|
||||
|
||||
silence_length_all += item["silence_len_list"]
|
||||
overlap_length_all += item["overlap_len_list"]
|
||||
|
||||
silence_ratio_all.append(item["silence_ratio"])
|
||||
overlap_ratio_all.append(item["overlap_ratio"])
|
||||
|
||||
actual_silence_mean = silence_duration / total_duration
|
||||
actual_silence_var = np.var(silence_ratio_all)
|
||||
actual_overlap_mean = overlap_duration / (total_duration - silence_duration)
|
||||
actual_overlap_var = np.var(overlap_ratio_all)
|
||||
|
||||
stats = OrderedDict()
|
||||
stats["total duration (hours)"] = f"{total_duration / 3600:.2f}"
|
||||
stats["number of sessions"] = len(rttm_list)
|
||||
stats["average session duration (seconds)"] = f"{total_duration / len(rttm_list):.2f}"
|
||||
stats["actual silence ratio mean/var"] = f"{actual_silence_mean:.4f}/{actual_silence_var:.4f}"
|
||||
stats["actual overlap ratio mean/var"] = f"{actual_overlap_mean:.4f}/{actual_overlap_var:.4f}"
|
||||
stats["expected silence ratio mean/var"] = f"{silence_mean}/{silence_var}"
|
||||
stats["expected overlap ratio mean/var"] = f"{overlap_mean}/{overlap_var}"
|
||||
stats["save_path"] = save_path
|
||||
|
||||
print("-----------------------------------------------")
|
||||
print(" Results ")
|
||||
print("-----------------------------------------------")
|
||||
for k, v in stats.items():
|
||||
print(k, ": ", v)
|
||||
print("-----------------------------------------------")
|
||||
|
||||
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(14, 14))
|
||||
fig.suptitle(
|
||||
f"Average session={total_duration/len(rttm_list):.2f} seconds, num sessions={len(rttm_list)}, total={total_duration/3600:.2f} hours"
|
||||
)
|
||||
sns.histplot(silence_ratio_all, ax=ax1)
|
||||
ax1.set_xlabel("Silence ratio in a session")
|
||||
ax1.set_title(
|
||||
f"Target silence mean={silence_mean}, var={silence_var}. \nActual silence ratio={actual_silence_mean:.4f}, var={actual_silence_var:.4f}"
|
||||
)
|
||||
|
||||
_, scale = expon.fit(silence_length_all, floc=0)
|
||||
sns.histplot(silence_length_all, ax=ax2)
|
||||
ax2.set_xlabel("Per-silence length in seconds")
|
||||
ax2.set_title(f"Per-silence length histogram, \nfitted exponential distribution with mean={scale:.4f}")
|
||||
|
||||
sns.histplot(overlap_ratio_all, ax=ax3)
|
||||
ax3.set_title(
|
||||
f"Target overlap mean={overlap_mean}, var={overlap_var}. \nActual ratio={actual_overlap_mean:.4f}, var={actual_overlap_var:.4f}"
|
||||
)
|
||||
ax3.set_xlabel("Overlap ratio in a session")
|
||||
_, scale2 = expon.fit(overlap_length_all, floc=0)
|
||||
sns.histplot(overlap_length_all, ax=ax4)
|
||||
ax4.set_title(f"Per overlap length histogram, \nfitted exponential distribution with mean={scale2:.4f}")
|
||||
ax4.set_xlabel("Duration in seconds")
|
||||
|
||||
if save_path:
|
||||
fig.savefig(save_path)
|
||||
print(f"Figure saved at: {save_path}")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def visualize_multispeaker_data(input_dir: str, output_dir: str, num_samples: int = 10) -> None:
|
||||
"""
|
||||
Visualize a set of randomly sampled data in the input directory
|
||||
|
||||
Args:
|
||||
input_dir (str): Path to the input directory
|
||||
output_dir (str): Path to the output directory
|
||||
num_samples (int): Number of samples to visualize
|
||||
"""
|
||||
rttm_list = list(Path(input_dir).glob("*.rttm"))
|
||||
idx_list = np.random.permutation(len(rttm_list))[:num_samples]
|
||||
print(f"Visualizing {num_samples} random samples")
|
||||
for idx in idx_list:
|
||||
rttm_file = rttm_list[idx]
|
||||
audio_file = rttm_file.parent / Path(rttm_file.stem + ".wav")
|
||||
output_file = Path(output_dir) / Path(rttm_file.stem + ".png")
|
||||
plot_sample_from_rttm(audio_file=audio_file, rttm_file=rttm_file, save_path=str(output_file), show=False)
|
||||
print(f"Sample plots saved at: {output_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("input_dir", default="", help="Input directory")
|
||||
parser.add_argument("-sd", "--session_dur", default=None, type=float, help="Duration per session in seconds")
|
||||
parser.add_argument("-sm", "--silence_mean", default=None, type=float, help="Expected silence ratio mean")
|
||||
parser.add_argument("-sv", "--silence_var", default=None, type=float, help="Expected silence ratio variance")
|
||||
parser.add_argument("-om", "--overlap_mean", default=None, type=float, help="Expected overlap ratio mean")
|
||||
parser.add_argument("-ov", "--overlap_var", default=None, type=float, help="Expected overlap ratio variance")
|
||||
parser.add_argument("-w", "--num_workers", default=1, type=int, help="Number of CPU workers to use")
|
||||
parser.add_argument("-s", "--num_samples", default=10, type=int, help="Number of random samples to plot")
|
||||
parser.add_argument("-o", "--output_dir", default="analysis/", type=str, help="Directory for saving output figure")
|
||||
parser.add_argument(
|
||||
"--precise", action="store_true", help="Set to get precise duration, with significant time cost"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print("Running with params:")
|
||||
pprint(vars(args))
|
||||
|
||||
output_dir = Path(args.output_dir)
|
||||
if output_dir.exists():
|
||||
print(f"Removing existing output directory: {args.output_dir}")
|
||||
shutil.rmtree(str(output_dir))
|
||||
output_dir.mkdir(parents=True)
|
||||
|
||||
run_multispeaker_data_analysis(
|
||||
input_dir=args.input_dir,
|
||||
session_dur=args.session_dur,
|
||||
silence_mean=args.silence_mean,
|
||||
silence_var=args.silence_var,
|
||||
overlap_mean=args.overlap_mean,
|
||||
overlap_var=args.overlap_var,
|
||||
precise=args.precise,
|
||||
save_path=str(Path(args.output_dir, "statistics.png")),
|
||||
num_workers=args.num_workers,
|
||||
)
|
||||
|
||||
visualize_multispeaker_data(input_dir=args.input_dir, output_dir=args.output_dir, num_samples=args.num_samples)
|
||||
|
||||
print("The multispeaker data analysis has been completed.")
|
||||
print(f"Please check the output directory: \n{args.output_dir}")
|
||||
@@ -0,0 +1,69 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import random
|
||||
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import create_manifest
|
||||
|
||||
random.seed(42)
|
||||
|
||||
"""
|
||||
This script creates manifest file for speaker diarization inference purposes.
|
||||
Useful to get manifest when you have list of audio files and optionally rttm and uem files for evaluation
|
||||
|
||||
Note: make sure basename for each file is unique and rttm files also has the corresponding base name for mapping
|
||||
"""
|
||||
|
||||
|
||||
def main(
|
||||
wav_path, text_path=None, rttm_path=None, uem_path=None, ctm_path=None, manifest_filepath=None, add_duration=False
|
||||
):
|
||||
create_manifest(
|
||||
wav_path,
|
||||
manifest_filepath,
|
||||
text_path=text_path,
|
||||
rttm_path=rttm_path,
|
||||
uem_path=uem_path,
|
||||
ctm_path=ctm_path,
|
||||
add_duration=add_duration,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--paths2audio_files", help="path to text file containing list of audio files", type=str, required=True
|
||||
)
|
||||
parser.add_argument("--paths2txt_files", help="path to text file containing list of transcription files", type=str)
|
||||
parser.add_argument("--paths2rttm_files", help="path to text file containing list of rttm files", type=str)
|
||||
parser.add_argument("--paths2uem_files", help="path to uem files", type=str)
|
||||
parser.add_argument("--paths2ctm_files", help="path to ctm files", type=str)
|
||||
parser.add_argument("--manifest_filepath", help="path to output manifest file", type=str, required=True)
|
||||
parser.add_argument(
|
||||
"--add_duration",
|
||||
help="add duration of audio files to output manifest files.",
|
||||
action='store_true',
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
main(
|
||||
args.paths2audio_files,
|
||||
args.paths2txt_files,
|
||||
args.paths2rttm_files,
|
||||
args.paths2uem_files,
|
||||
args.paths2ctm_files,
|
||||
args.manifest_filepath,
|
||||
args.add_duration,
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from nemo.collections.common.tokenizers import CanaryTokenizer
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("output_dir", type=click.Path())
|
||||
def main(output_dir: str) -> None:
|
||||
"""
|
||||
Builds the special tokens tokenizer for NVIDIA Canary-1B model.
|
||||
It's intended to be used with CanaryTokenizer (a specialized AggregateTokenizer)
|
||||
under name ``spl_tokens``.
|
||||
"""
|
||||
CanaryTokenizer.build_special_tokenizer(
|
||||
[
|
||||
"<|endoftext|>",
|
||||
"<|startoftranscript|>",
|
||||
"<|transcribe|>",
|
||||
"<|translate|>",
|
||||
"<|nopnc|>",
|
||||
"<|pnc|>",
|
||||
"<|nospeech|>",
|
||||
]
|
||||
+ [
|
||||
"<|en|>",
|
||||
"<|es|>",
|
||||
"<|de|>",
|
||||
"<|fr|>",
|
||||
]
|
||||
+ [f"<|spltoken{i}|>" for i in range(16)],
|
||||
model_dir=output_dir,
|
||||
force_rebuild=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python
|
||||
# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
import math
|
||||
|
||||
import click
|
||||
|
||||
from nemo.collections.common.tokenizers import CanaryTokenizer
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("output_dir", type=click.Path())
|
||||
def main(output_dir: str) -> None:
|
||||
"""
|
||||
Builds the special tokens tokenizer for NVIDIA Canary-2.0 model.
|
||||
It's intended to be used with CanaryTokenizer (a specialized AggregateTokenizer)
|
||||
under name ``spl_tokens``.
|
||||
"""
|
||||
|
||||
tokens = (
|
||||
[
|
||||
# Generic special tokens
|
||||
"<|endoftext|>",
|
||||
"<|startoftranscript|>",
|
||||
"<|nopnc|>",
|
||||
"<|pnc|>",
|
||||
"<|nospeech|>",
|
||||
"<|startofcontext|>",
|
||||
"<|itn|>",
|
||||
"<|noitn|>",
|
||||
"<|timestamp|>",
|
||||
"<|notimestamp|>",
|
||||
"<|diarize|>",
|
||||
"<|nodiarize|>",
|
||||
"<|spkchange|>",
|
||||
"<|audioseparator|>",
|
||||
"<|emo:undefined|>",
|
||||
"<|emo:neutral|>",
|
||||
"<|emo:happy|>",
|
||||
"<|emo:sad|>",
|
||||
"<|emo:angry|>",
|
||||
]
|
||||
# Language special tokens
|
||||
+ [
|
||||
"<|unklang|>",
|
||||
]
|
||||
+ ISO_LANGS
|
||||
# Timestamp frame special tokens
|
||||
+ [f"<|{i}|>" for i in range(900)]
|
||||
# Speaker indicator special tokens
|
||||
+ [f"<|spk{i}|>" for i in range(16)]
|
||||
)
|
||||
|
||||
num_tokens = len(tokens) + 3 # count "<pad>", "<unk>", "_" too
|
||||
print(f"We have {num_tokens} special tokens.")
|
||||
final_num_tokens = next_multiple_of_64(num_tokens)
|
||||
num_extra_tokens = final_num_tokens - num_tokens
|
||||
print(f"Adding extra {num_extra_tokens} unused special tokens for a total vocab size of {final_num_tokens}")
|
||||
|
||||
tokens += [
|
||||
# Timestamp related special tokens
|
||||
f"<|spltoken{i}|>"
|
||||
for i in range(num_extra_tokens)
|
||||
]
|
||||
|
||||
tokenizer = CanaryTokenizer.build_special_tokenizer(
|
||||
tokens=tokens,
|
||||
model_dir=output_dir,
|
||||
force_rebuild=True,
|
||||
)
|
||||
|
||||
assert tokenizer.vocab_size == 1152, tokenizer.vocab_size
|
||||
|
||||
|
||||
def next_multiple_of_64(n):
|
||||
return ((n + 63) // 64) * 64
|
||||
|
||||
|
||||
ISO_LANGS = [
|
||||
"<|aa|>",
|
||||
"<|ab|>",
|
||||
"<|af|>",
|
||||
"<|ak|>",
|
||||
"<|sq|>",
|
||||
"<|am|>",
|
||||
"<|ar|>",
|
||||
"<|an|>",
|
||||
"<|hy|>",
|
||||
"<|as|>",
|
||||
"<|av|>",
|
||||
"<|ae|>",
|
||||
"<|ay|>",
|
||||
"<|az|>",
|
||||
"<|bm|>",
|
||||
"<|ba|>",
|
||||
"<|eu|>",
|
||||
"<|be|>",
|
||||
"<|bn|>",
|
||||
"<|bi|>",
|
||||
"<|bs|>",
|
||||
"<|br|>",
|
||||
"<|bg|>",
|
||||
"<|my|>",
|
||||
"<|ca|>",
|
||||
"<|ch|>",
|
||||
"<|ce|>",
|
||||
"<|ny|>",
|
||||
"<|zh|>",
|
||||
"<|cu|>",
|
||||
"<|cv|>",
|
||||
"<|kw|>",
|
||||
"<|co|>",
|
||||
"<|cr|>",
|
||||
"<|hr|>",
|
||||
"<|cs|>",
|
||||
"<|da|>",
|
||||
"<|dv|>",
|
||||
"<|nl|>",
|
||||
"<|dz|>",
|
||||
"<|en|>",
|
||||
"<|eo|>",
|
||||
"<|et|>",
|
||||
"<|ee|>",
|
||||
"<|fo|>",
|
||||
"<|fj|>",
|
||||
"<|fi|>",
|
||||
"<|fr|>",
|
||||
"<|fy|>",
|
||||
"<|ff|>",
|
||||
"<|gd|>",
|
||||
"<|gl|>",
|
||||
"<|lg|>",
|
||||
"<|ka|>",
|
||||
"<|de|>",
|
||||
"<|el|>",
|
||||
"<|kl|>",
|
||||
"<|gn|>",
|
||||
"<|gu|>",
|
||||
"<|ht|>",
|
||||
"<|ha|>",
|
||||
"<|he|>",
|
||||
"<|hz|>",
|
||||
"<|hi|>",
|
||||
"<|ho|>",
|
||||
"<|hu|>",
|
||||
"<|is|>",
|
||||
"<|io|>",
|
||||
"<|ig|>",
|
||||
"<|id|>",
|
||||
"<|ia|>",
|
||||
"<|ie|>",
|
||||
"<|iu|>",
|
||||
"<|ik|>",
|
||||
"<|ga|>",
|
||||
"<|it|>",
|
||||
"<|ja|>",
|
||||
"<|jv|>",
|
||||
"<|kn|>",
|
||||
"<|kr|>",
|
||||
"<|ks|>",
|
||||
"<|kk|>",
|
||||
"<|km|>",
|
||||
"<|ki|>",
|
||||
"<|rw|>",
|
||||
"<|ky|>",
|
||||
"<|kv|>",
|
||||
"<|kg|>",
|
||||
"<|ko|>",
|
||||
"<|kj|>",
|
||||
"<|ku|>",
|
||||
"<|lo|>",
|
||||
"<|la|>",
|
||||
"<|lv|>",
|
||||
"<|li|>",
|
||||
"<|ln|>",
|
||||
"<|lt|>",
|
||||
"<|lu|>",
|
||||
"<|lb|>",
|
||||
"<|mk|>",
|
||||
"<|mg|>",
|
||||
"<|ms|>",
|
||||
"<|ml|>",
|
||||
"<|mt|>",
|
||||
"<|gv|>",
|
||||
"<|mi|>",
|
||||
"<|mr|>",
|
||||
"<|mh|>",
|
||||
"<|mn|>",
|
||||
"<|na|>",
|
||||
"<|nv|>",
|
||||
"<|nd|>",
|
||||
"<|nr|>",
|
||||
"<|ng|>",
|
||||
"<|ne|>",
|
||||
"<|no|>",
|
||||
"<|nb|>",
|
||||
"<|nn|>",
|
||||
"<|oc|>",
|
||||
"<|oj|>",
|
||||
"<|or|>",
|
||||
"<|om|>",
|
||||
"<|os|>",
|
||||
"<|pi|>",
|
||||
"<|ps|>",
|
||||
"<|fa|>",
|
||||
"<|pl|>",
|
||||
"<|pt|>",
|
||||
"<|pa|>",
|
||||
"<|qu|>",
|
||||
"<|ro|>",
|
||||
"<|rm|>",
|
||||
"<|rn|>",
|
||||
"<|ru|>",
|
||||
"<|se|>",
|
||||
"<|sm|>",
|
||||
"<|sg|>",
|
||||
"<|sa|>",
|
||||
"<|sc|>",
|
||||
"<|sr|>",
|
||||
"<|sn|>",
|
||||
"<|sd|>",
|
||||
"<|si|>",
|
||||
"<|sk|>",
|
||||
"<|sl|>",
|
||||
"<|so|>",
|
||||
"<|st|>",
|
||||
"<|es|>",
|
||||
"<|su|>",
|
||||
"<|sw|>",
|
||||
"<|ss|>",
|
||||
"<|sv|>",
|
||||
"<|tl|>",
|
||||
"<|ty|>",
|
||||
"<|tg|>",
|
||||
"<|ta|>",
|
||||
"<|tt|>",
|
||||
"<|te|>",
|
||||
"<|th|>",
|
||||
"<|bo|>",
|
||||
"<|ti|>",
|
||||
"<|to|>",
|
||||
"<|ts|>",
|
||||
"<|tn|>",
|
||||
"<|tr|>",
|
||||
"<|tk|>",
|
||||
"<|tw|>",
|
||||
"<|ug|>",
|
||||
"<|uk|>",
|
||||
"<|ur|>",
|
||||
"<|uz|>",
|
||||
"<|ve|>",
|
||||
"<|vi|>",
|
||||
"<|vo|>",
|
||||
"<|wa|>",
|
||||
"<|cy|>",
|
||||
"<|wo|>",
|
||||
"<|xh|>",
|
||||
"<|ii|>",
|
||||
"<|yi|>",
|
||||
"<|yo|>",
|
||||
"<|za|>",
|
||||
"<|zu|>",
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,17 @@
|
||||
# Scripts for creation of synthetic code-switched data from monolingual sources
|
||||
Follow the 2 steps listed below in order -
|
||||
|
||||
|
||||
1. Create the (intermediate) manifest file using `code_switching_manifest_creation.py`. It's usage is as follows:
|
||||
|
||||
`python code_switching_manifest_creation.py --manifest_language1 <absolute path of Language 1's manifest file> --manifest_language2 <absolute path of Language 2's manifest file> --manifest_save_path <absolute path to save the created manifest> --id_language1 <language code for language 1 (e.g. en)> --id_language2 <language code for language 2 (e.g. es)> --max_sample_duration_sec <maximum duration of generated sample in seconds> --min_sample_duration_sec <maximum duration of generated sample in seconds> --dataset_size_required_hrs <size of generated synthetic dataset required in hrs>`
|
||||
|
||||
Estimated runtime for dataset_size_required_hrs=10,000 is ~2 mins
|
||||
|
||||
2. Create the synthetic audio data and the corresponding manifest file using `code_switching_audio_data_creation.py` It's usage is as follows:
|
||||
|
||||
`python code_switching_audio_data_creation.py --manifest_path <absolute path to intermediate CS manifest generated in step 1> --audio_save_folder_path <absolute path to directory where you want to save the synthesized audios> --manifest_save_path <absolute path to save the created manifest> --audio_normalized_amplitude <scaled normalized amplitude desired> --cs_data_sampling_rate <desired sampling rate for generated audios> --sample_beginning_pause_msec <pause to be added to the beginning of the generated sample in milli seconds> --sample_joining_pause_msec <pause to be added between segments while joining, in milli seconds> --sample_end_pause_msec <pause to be added to the end of the generated sample in milli seconds> --is_lid_manifest <boolean to create manifest in the multi-sample lid format for the text field, true by default> --workers <number of worker processes>`
|
||||
|
||||
Example of the multi-sample LID format: ```[{“str”:“esta muestra ” “lang”:”es”},{“str”:“was generated synthetically”: “lang”:”en”}]```
|
||||
|
||||
Estimated runtime for generating a 10,000 hour corpus is ~40 hrs with a single worker
|
||||
@@ -0,0 +1,288 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import librosa
|
||||
import numpy as np
|
||||
from joblib import Parallel, delayed
|
||||
from scipy.io import wavfile
|
||||
from tqdm import tqdm
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest
|
||||
|
||||
parser = argparse.ArgumentParser(description='Create synthetic code-switching data audio data from monolingual data')
|
||||
parser.add_argument("--manifest_path", default=None, type=str, help='Path to CS indermediate manifest', required=True)
|
||||
parser.add_argument(
|
||||
"--audio_save_folder_path",
|
||||
default=None,
|
||||
type=str,
|
||||
help='Path to directory where created synthetic set would be saved',
|
||||
required=True,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--manifest_save_path", default=None, type=str, help='Path to save the created manifest', required=True
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio_normalized_amplitude", default=15000, type=int, help='Normalized amplitdue of audio samples'
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cs_data_sampling_rate",
|
||||
default=16000,
|
||||
type=int,
|
||||
help='Desired sampling rate for the audios in the generated dataset',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample_beginning_pause_msec",
|
||||
default=20,
|
||||
type=int,
|
||||
help='Pause to be added at the beginning of the sample (msec)',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample_joining_pause_msec",
|
||||
default=100,
|
||||
type=int,
|
||||
help='Pause to be added between different phrases of the sample (msec)',
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample_end_pause_msec", default=20, type=int, help='Pause to be added at the end of the sample (msec)'
|
||||
)
|
||||
parser.add_argument(
|
||||
"--is_lid_manifest",
|
||||
default=True,
|
||||
type=bool,
|
||||
help='If true, generate manifest in the multi-sample lid format, else the standard manifest format',
|
||||
)
|
||||
parser.add_argument("--workers", default=1, type=int, help='Number of worker processes')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def split_list(input_list: list, num_splits: int):
|
||||
"""
|
||||
Args:
|
||||
input_list: the input list to split
|
||||
num_splits: number of splits required
|
||||
|
||||
Returns:
|
||||
iterator of split lists
|
||||
|
||||
"""
|
||||
k, m = divmod(len(input_list), num_splits)
|
||||
return (input_list[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(num_splits))
|
||||
|
||||
|
||||
def combine_manifests(manifest_save_path: str, num_split: int):
|
||||
"""
|
||||
Args:
|
||||
manifest_save_path: absolute path to save the combined manifest
|
||||
num_splits: number of splits of manifest
|
||||
|
||||
Returns:
|
||||
num_samples_combined: the total number of samples in the generated dataset
|
||||
"""
|
||||
num_samples_combined = 0
|
||||
base_directory = os.path.dirname(manifest_save_path)
|
||||
|
||||
with open(manifest_save_path, 'w') as outfile:
|
||||
for i in range(num_split):
|
||||
split_manifest_path = base_directory + '/temp_' + str(i) + '.json'
|
||||
data_split = read_manifest(split_manifest_path)
|
||||
|
||||
for elem in data_split:
|
||||
s = json.dumps(elem)
|
||||
outfile.write(s + '\n')
|
||||
num_samples_combined += 1
|
||||
|
||||
# removing the intermediate file
|
||||
os.remove(split_manifest_path)
|
||||
|
||||
return num_samples_combined
|
||||
|
||||
|
||||
def create_cs_data(
|
||||
intermediate_cs_manifest_list: list,
|
||||
audio_save_folder: str,
|
||||
manfest_save_path: str,
|
||||
audio_amplitude_normalization: int,
|
||||
pause_beg_msec: int,
|
||||
pause_join_msec: int,
|
||||
pause_end_msec: int,
|
||||
cs_data_sampling_rate: int,
|
||||
is_lid_manifest: bool,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
intermediate_cs_manifest_list: the intermediate cs manifest obtained from code_switching_manifest_creation.py as a list
|
||||
audio_save_folder: Absolute path to save the generated audio samples
|
||||
manfest_save_path: Absolute path to save the corresponding manifest
|
||||
audio_amplitude_normalization: The amplitude to scale to after normalization
|
||||
pause_beg_msec: Pause to be added at the beginning of the sample (msec)
|
||||
pause_join_msec: Pause to be added between different phrases of the sample (msec)
|
||||
pause_end_msec: Pause to be added at the end of the sample (msec)
|
||||
cs_data_sampling_rate: Desired sampling rate of the generated samples
|
||||
is_lid_manifest: If true, generate manifest in the multi-sample lid format, else the standard manifest format
|
||||
|
||||
Returns:
|
||||
|
||||
"""
|
||||
|
||||
fs = cs_data_sampling_rate
|
||||
incorrect_sample_flag = 0
|
||||
|
||||
with open(manfest_save_path, 'w') as outfile:
|
||||
for data in tqdm(intermediate_cs_manifest_list):
|
||||
|
||||
combined_audio = []
|
||||
|
||||
staring_pause = np.zeros(int(pause_beg_msec * fs / 1000))
|
||||
combined_audio += list(staring_pause)
|
||||
|
||||
text_entry_list = []
|
||||
for index in range(len(data['lang_ids'])):
|
||||
|
||||
phrase_entry = {}
|
||||
# dictionary to store the phrase information which will be added to the complete sentence
|
||||
|
||||
data_sample, fs_sample = librosa.load(data['paths'][index], sr=fs)
|
||||
# Alternative- fs_sample, data_sample = wavfile.read(data['paths'][index])
|
||||
|
||||
if fs_sample != fs:
|
||||
logging.error('Sampling rate error inside create_cs_data function')
|
||||
exit
|
||||
|
||||
# Remove leading and trailing zeros
|
||||
data_sample = np.trim_zeros(data_sample)
|
||||
|
||||
# take care of empty arrays: rare
|
||||
if data_sample.size == 0:
|
||||
incorrect_sample_flag = 1
|
||||
continue
|
||||
|
||||
# normalizing data
|
||||
data_sample_norm = (
|
||||
data_sample
|
||||
/ np.maximum(np.abs(data_sample.max()), np.abs(data_sample.min()))
|
||||
* audio_amplitude_normalization
|
||||
)
|
||||
|
||||
combined_audio += list(data_sample_norm)
|
||||
|
||||
phrase_entry['str'] = data['texts'][index]
|
||||
phrase_entry['lang'] = data['lang_ids'][index]
|
||||
|
||||
text_entry_list.append(phrase_entry)
|
||||
|
||||
# adding small pause between semgments
|
||||
if index != (len(data['lang_ids']) - 1):
|
||||
pause = np.zeros(int(pause_join_msec * fs / 1000))
|
||||
combined_audio += list(pause)
|
||||
|
||||
if incorrect_sample_flag == 1:
|
||||
incorrect_sample_flag = 0
|
||||
continue
|
||||
|
||||
ending_pause = np.zeros(int(pause_end_msec * fs / 1000))
|
||||
combined_audio += list(ending_pause)
|
||||
|
||||
sample_id = data['uid']
|
||||
audio_file_path = audio_save_folder + '/' + str(sample_id) + ".wav"
|
||||
|
||||
# saving audio
|
||||
wavfile.write(audio_file_path, fs, np.array(combined_audio).astype(np.int16))
|
||||
# Alternative- librosa.output.write_wav(audio_file_path, combined_audio, fs)
|
||||
|
||||
metadata_json = {}
|
||||
metadata_json['audio_filepath'] = audio_file_path
|
||||
metadata_json['duration'] = float(len(combined_audio) / fs)
|
||||
if is_lid_manifest:
|
||||
metadata_json['text'] = text_entry_list
|
||||
else:
|
||||
metadata_json['text'] = ' '.join(data['texts'])
|
||||
|
||||
metadata_json['language_ids'] = data['lang_ids']
|
||||
metadata_json['original_texts'] = data['texts']
|
||||
metadata_json['original_paths'] = data['paths']
|
||||
metadata_json['original_durations'] = data['durations']
|
||||
|
||||
s = json.dumps(metadata_json)
|
||||
outfile.write(s + '\n')
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
cs_intermediate_manifest_path = args.manifest_path
|
||||
audio_save_folder = args.audio_save_folder_path
|
||||
manifest_save_path = args.manifest_save_path
|
||||
audio_amplitude_normalization = args.audio_normalized_amplitude
|
||||
pause_beg_msec = args.sample_beginning_pause_msec
|
||||
pause_join_msec = args.sample_joining_pause_msec
|
||||
pause_end_msec = args.sample_end_pause_msec
|
||||
cs_data_sampling_rate = args.cs_data_sampling_rate
|
||||
is_lid_manifest = args.is_lid_manifest
|
||||
num_process = args.workers
|
||||
|
||||
# Sanity Checks
|
||||
if (cs_intermediate_manifest_path is None) or (not os.path.exists(cs_intermediate_manifest_path)):
|
||||
logging.error('Please provide correct CS manifest (obtained from code_switching_manifest_creation.py)')
|
||||
exit
|
||||
|
||||
if (audio_save_folder is None) or (not os.path.exists(audio_save_folder)):
|
||||
logging.error('audio_save_folder_path is incorrect or does not exist')
|
||||
exit
|
||||
|
||||
if manifest_save_path is None:
|
||||
logging.error('Please provide valid manifest_save_path')
|
||||
exit
|
||||
|
||||
# Reading data
|
||||
logging.info('Reading manifests')
|
||||
intermediate_cs_manifest = read_manifest(cs_intermediate_manifest_path)
|
||||
|
||||
# Spliting the data
|
||||
data_split = split_list(intermediate_cs_manifest, num_process)
|
||||
|
||||
# Creating Audio data
|
||||
logging.info('Creating synthetic audio data')
|
||||
base_directory = os.path.dirname(manifest_save_path)
|
||||
|
||||
Parallel(n_jobs=num_process)(
|
||||
delayed(create_cs_data)(
|
||||
split_manifest,
|
||||
audio_save_folder,
|
||||
base_directory + '/temp_' + str(idx) + '.json',
|
||||
audio_amplitude_normalization,
|
||||
pause_beg_msec,
|
||||
pause_join_msec,
|
||||
pause_end_msec,
|
||||
cs_data_sampling_rate,
|
||||
is_lid_manifest,
|
||||
)
|
||||
for idx, split_manifest in enumerate(data_split)
|
||||
)
|
||||
|
||||
# Combining manifests
|
||||
num_samples_combined = combine_manifests(manifest_save_path, num_process)
|
||||
|
||||
print("Synthetic CS audio data saved at :", audio_save_folder)
|
||||
print("Synthetic CS manifest saved at :", manifest_save_path)
|
||||
print("Total number of samples in the generated dataset :", str(num_samples_combined))
|
||||
|
||||
logging.info('Done!')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,177 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from nemo.collections.asr.parts.utils.manifest_utils import read_manifest, write_manifest
|
||||
|
||||
# Checks -
|
||||
# (Recommendation) Please normalize the text for each language (avoid numbers, special characters, punctuation)
|
||||
# Please ensure that the audio_filepaths are absolute locations
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser(description='Create synthetic code-switching data manifest from monolingual data')
|
||||
|
||||
parser.add_argument("--manifest_language1", default=None, type=str, help='Manifest file for language 1', required=True)
|
||||
parser.add_argument("--manifest_language2", default=None, type=str, help='Manifest file for language 2', required=True)
|
||||
parser.add_argument(
|
||||
"--manifest_save_path", default=None, type=str, help='Path to save created CS indermediate manifest', required=True
|
||||
)
|
||||
parser.add_argument(
|
||||
"--id_language1", default=None, type=str, help='Identifier for language 1, eg: en, es, hi', required=True
|
||||
)
|
||||
parser.add_argument(
|
||||
"--id_language2", default=None, type=str, help='Identifier for language 2, eg: en, es, hi', required=True
|
||||
)
|
||||
parser.add_argument("--max_sample_duration_sec", default=19, type=int, help='Maximum duration of sample (sec)')
|
||||
parser.add_argument("--min_sample_duration_sec", default=16, type=int, help='Minimum duration of sample (sec)')
|
||||
parser.add_argument("--dataset_size_required_hrs", default=1, type=int, help='Duration of dataset required (hrs)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
def create_cs_manifest(
|
||||
data_lang_0: list,
|
||||
data_lang_1: list,
|
||||
lid_lang_0: str,
|
||||
lid_lang_1: str,
|
||||
max_sample_duration_sec: int,
|
||||
min_sample_duration_sec: int,
|
||||
data_requirement_hrs: int,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
data_lang_0: Manifest entries from first langauge
|
||||
data_lang_1: Manifest entries from second langauge
|
||||
lid_lang_0: Language ID marker for first langauge
|
||||
lid_lang_1: Language ID marker for second langauge
|
||||
max_sample_duration_sec: Maximum permissible duration of generated CS sample in sec
|
||||
min_sample_duration_sec: Minimum permissible duration of generated CS sample in sec
|
||||
data_requirement_hrs: Required size of generated corpus
|
||||
|
||||
Returns:
|
||||
Created synthetic CS manifest as list
|
||||
|
||||
"""
|
||||
|
||||
total_duration = 0
|
||||
constructed_data = []
|
||||
sample_id = 0
|
||||
|
||||
num_samples_lang0 = len(data_lang_0)
|
||||
num_samples_lang1 = len(data_lang_1)
|
||||
|
||||
while total_duration < (data_requirement_hrs * 3600):
|
||||
|
||||
created_sample_duration_sec = 0
|
||||
created_sample_dict = {}
|
||||
created_sample_dict['lang_ids'] = []
|
||||
created_sample_dict['texts'] = []
|
||||
created_sample_dict['paths'] = []
|
||||
created_sample_dict['durations'] = []
|
||||
|
||||
while created_sample_duration_sec < min_sample_duration_sec:
|
||||
|
||||
lang_selection = random.randint(0, 1)
|
||||
|
||||
if lang_selection == 0:
|
||||
index = random.randint(0, num_samples_lang0 - 1)
|
||||
sample = data_lang_0[index]
|
||||
lang_id = lid_lang_0
|
||||
else:
|
||||
index = random.randint(0, num_samples_lang1 - 1)
|
||||
sample = data_lang_1[index]
|
||||
lang_id = lid_lang_1
|
||||
|
||||
if (created_sample_duration_sec + sample['duration']) > max_sample_duration_sec:
|
||||
continue
|
||||
else:
|
||||
created_sample_duration_sec += sample['duration']
|
||||
created_sample_dict['lang_ids'].append(lang_id)
|
||||
created_sample_dict['texts'].append(sample['text'])
|
||||
created_sample_dict['paths'].append(sample['audio_filepath'])
|
||||
created_sample_dict['durations'].append(sample['duration'])
|
||||
|
||||
created_sample_dict['total_duration'] = created_sample_duration_sec
|
||||
|
||||
# adding a uid which will be used to save the generated audio file later
|
||||
created_sample_dict['uid'] = sample_id
|
||||
sample_id += 1
|
||||
|
||||
constructed_data.append(created_sample_dict)
|
||||
total_duration += created_sample_duration_sec
|
||||
|
||||
return constructed_data
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
manifest0 = args.manifest_language1
|
||||
manifest1 = args.manifest_language2
|
||||
lid0 = args.id_language1
|
||||
lid1 = args.id_language2
|
||||
min_sample_duration = args.min_sample_duration_sec
|
||||
max_sample_duration = args.max_sample_duration_sec
|
||||
dataset_requirement = args.dataset_size_required_hrs
|
||||
manifest_save_path = args.manifest_save_path
|
||||
|
||||
# Sanity Checks
|
||||
if (manifest0 is None) or (not os.path.exists(manifest0)):
|
||||
logging.error('Manifest for language 1 is incorrect')
|
||||
exit
|
||||
|
||||
if (manifest1 is None) or (not os.path.exists(manifest1)):
|
||||
logging.error('Manifest for language 2 is incorrect')
|
||||
exit
|
||||
|
||||
if lid0 is None:
|
||||
logging.error('Please provide correct language code for language 1')
|
||||
exit
|
||||
|
||||
if lid1 is None:
|
||||
logging.error('Please provide correct language code for language 2')
|
||||
exit
|
||||
|
||||
if manifest_save_path is None:
|
||||
logging.error('Please provide correct manifest save path')
|
||||
exit
|
||||
|
||||
if min_sample_duration >= max_sample_duration:
|
||||
logging.error('Please ensure max_sample_duration > min_sample_duration')
|
||||
exit
|
||||
|
||||
# Reading data
|
||||
logging.info('Reading manifests')
|
||||
data_language0 = read_manifest(manifest0)
|
||||
data_language1 = read_manifest(manifest1)
|
||||
|
||||
# Creating the CS data Manifest
|
||||
logging.info('Creating CS manifest')
|
||||
constructed_data = create_cs_manifest(
|
||||
data_language0, data_language1, lid0, lid1, max_sample_duration, min_sample_duration, dataset_requirement
|
||||
)
|
||||
|
||||
# Saving Manifest
|
||||
logging.info('saving manifest')
|
||||
write_manifest(manifest_save_path, constructed_data)
|
||||
|
||||
print("Synthetic CS manifest saved at :", manifest_save_path)
|
||||
|
||||
logging.info('Done!')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,308 @@
|
||||
# Copyright (c) 2022, NVIDIA CORPORATION. All rights reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import lightning.pytorch as pl
|
||||
import torch
|
||||
from omegaconf import MISSING, OmegaConf
|
||||
from sklearn.model_selection import ParameterGrid
|
||||
|
||||
from nemo.collections.asr.models import ASRModel, EncDecRNNTModel
|
||||
from nemo.collections.asr.parts.submodules.ctc_decoding import CTCDecodingConfig
|
||||
from nemo.collections.asr.parts.submodules.rnnt_decoding import RNNTDecodingConfig
|
||||
from nemo.collections.asr.parts.utils.asr_confidence_benchmarking_utils import (
|
||||
apply_confidence_parameters,
|
||||
run_confidence_benchmark,
|
||||
)
|
||||
from nemo.collections.asr.parts.utils.asr_confidence_utils import ConfidenceConfig
|
||||
from nemo.core.config import hydra_runner
|
||||
from nemo.utils import logging, model_utils
|
||||
|
||||
"""
|
||||
Get confidence metrics and curve plots for a given model, dataset, and confidence parameters.
|
||||
|
||||
# Arguments
|
||||
model_path: Path to .nemo ASR checkpoint
|
||||
pretrained_name: Name of pretrained ASR model (from NGC registry)
|
||||
dataset_manifest: Path to dataset JSON manifest file (in NeMo format)
|
||||
output_dir: Output directory to store a report and curve plot directories
|
||||
|
||||
batch_size: batch size during inference
|
||||
num_workers: number of workers during inference
|
||||
|
||||
cuda: Optional int to enable or disable execution of model on certain CUDA device
|
||||
amp: Bool to decide if Automatic Mixed Precision should be used during inference
|
||||
audio_type: Str filetype of the audio. Supported = wav, flac, mp3
|
||||
|
||||
target_level: Word- or token-level confidence. Supported = word, token, auto (for computing both word and token)
|
||||
confidence_cfg: Config with confidence parameters
|
||||
grid_params: Dictionary with lists of parameters to iteratively benchmark on
|
||||
|
||||
# Usage
|
||||
ASR model can be specified by either "model_path" or "pretrained_name".
|
||||
Data for transcription are defined with "dataset_manifest".
|
||||
Results are returned as a benchmark report and curve plots.
|
||||
|
||||
python benchmark_asr_confidence.py \
|
||||
model_path=null \
|
||||
pretrained_name=null \
|
||||
dataset_manifest="" \
|
||||
output_dir="" \
|
||||
batch_size=64 \
|
||||
num_workers=8 \
|
||||
cuda=0 \
|
||||
amp=True \
|
||||
target_level="word" \
|
||||
confidence_cfg.exclude_blank=False \
|
||||
'grid_params="{\"aggregation\": [\"min\", \"prod\"], \"alpha\": [0.33, 0.5]}"'
|
||||
"""
|
||||
|
||||
|
||||
def get_experiment_params(cfg):
|
||||
"""Get experiment parameters from a confidence config and generate the experiment name.
|
||||
|
||||
Returns:
|
||||
List of experiment parameters.
|
||||
String with the experiment name.
|
||||
"""
|
||||
blank = "no_blank" if cfg.exclude_blank else "blank"
|
||||
duration = "duration" if cfg.tdt_include_duration else "no_duration"
|
||||
aggregation = cfg.aggregation
|
||||
method_name = cfg.method_cfg.name
|
||||
alpha = cfg.method_cfg.alpha
|
||||
if method_name == "entropy":
|
||||
entropy_type = cfg.method_cfg.entropy_type
|
||||
entropy_norm = cfg.method_cfg.entropy_norm
|
||||
experiment_param_list = [
|
||||
aggregation,
|
||||
str(cfg.exclude_blank),
|
||||
str(cfg.tdt_include_duration),
|
||||
method_name,
|
||||
entropy_type,
|
||||
entropy_norm,
|
||||
str(alpha),
|
||||
]
|
||||
experiment_str = "-".join([aggregation, blank, duration, method_name, entropy_type, entropy_norm, str(alpha)])
|
||||
else:
|
||||
experiment_param_list = [
|
||||
aggregation,
|
||||
str(cfg.exclude_blank),
|
||||
str(cfg.tdt_include_duration),
|
||||
method_name,
|
||||
"-",
|
||||
"-",
|
||||
str(alpha),
|
||||
]
|
||||
experiment_str = "-".join([aggregation, blank, duration, method_name, str(alpha)])
|
||||
return experiment_param_list, experiment_str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfidenceBenchmarkingConfig:
|
||||
# Required configs
|
||||
model_path: Optional[str] = None # Path to a .nemo file
|
||||
pretrained_name: Optional[str] = None # Name of a pretrained model
|
||||
dataset_manifest: str = MISSING
|
||||
output_dir: str = MISSING
|
||||
|
||||
# General configs
|
||||
batch_size: int = 32
|
||||
num_workers: int = 4
|
||||
|
||||
# Set `cuda` to int to define CUDA device. If 'None', will look for CUDA
|
||||
# device anyway, and do inference on CPU only if CUDA device is not found.
|
||||
# If `cuda` is a negative number, inference will be on CPU only.
|
||||
cuda: Optional[int] = None
|
||||
amp: bool = False
|
||||
audio_type: str = "wav"
|
||||
|
||||
# Confidence configs
|
||||
target_level: str = "auto" # Choices: "word", "token", "auto" (for both word- and token-level confidence)
|
||||
confidence_cfg: ConfidenceConfig = field(
|
||||
default_factory=lambda: ConfidenceConfig(preserve_word_confidence=True, preserve_token_confidence=True)
|
||||
)
|
||||
grid_params: Optional[str] = None # a dictionary with lists of parameters to iteratively benchmark on
|
||||
|
||||
|
||||
@hydra_runner(config_name="ConfidenceBenchmarkingConfig", schema=ConfidenceBenchmarkingConfig)
|
||||
def main(cfg: ConfidenceBenchmarkingConfig):
|
||||
torch.set_grad_enabled(False)
|
||||
|
||||
logging.info(f'Hydra config: {OmegaConf.to_yaml(cfg)}')
|
||||
|
||||
if is_dataclass(cfg):
|
||||
cfg = OmegaConf.structured(cfg)
|
||||
|
||||
if cfg.model_path is None and cfg.pretrained_name is None:
|
||||
raise ValueError("Both cfg.model_path and cfg.pretrained_name cannot be None!")
|
||||
|
||||
# setup GPU
|
||||
if cfg.cuda is None:
|
||||
if torch.cuda.is_available():
|
||||
device = [0] # use 0th CUDA device
|
||||
accelerator = 'gpu'
|
||||
else:
|
||||
device = 1
|
||||
accelerator = 'cpu'
|
||||
else:
|
||||
device = [cfg.cuda]
|
||||
accelerator = 'gpu'
|
||||
|
||||
map_location = torch.device('cuda:{}'.format(device[0]) if accelerator == 'gpu' else 'cpu')
|
||||
|
||||
# setup model
|
||||
if cfg.model_path is not None:
|
||||
# restore model from .nemo file path
|
||||
model_cfg = ASRModel.restore_from(restore_path=cfg.model_path, return_config=True)
|
||||
classpath = model_cfg.target # original class path
|
||||
imported_class = model_utils.import_class_by_path(classpath) # type: ASRModel
|
||||
logging.info(f"Restoring model : {imported_class.__name__}")
|
||||
asr_model = imported_class.restore_from(
|
||||
restore_path=cfg.model_path, map_location=map_location
|
||||
) # type: ASRModel
|
||||
else:
|
||||
# restore model by name
|
||||
asr_model = ASRModel.from_pretrained(
|
||||
model_name=cfg.pretrained_name, map_location=map_location
|
||||
) # type: ASRModel
|
||||
|
||||
trainer = pl.Trainer(devices=device, accelerator=accelerator)
|
||||
asr_model.set_trainer(trainer)
|
||||
asr_model = asr_model.eval()
|
||||
|
||||
# Check if ctc or rnnt model
|
||||
is_rnnt = isinstance(asr_model, EncDecRNNTModel)
|
||||
|
||||
# Check that the model has the `change_decoding_strategy` method
|
||||
if not hasattr(asr_model, 'change_decoding_strategy'):
|
||||
raise RuntimeError("The asr_model you are using must have the `change_decoding_strategy` method.")
|
||||
|
||||
# get filenames and reference texts from manifest
|
||||
filepaths = []
|
||||
reference_texts = []
|
||||
if os.stat(cfg.dataset_manifest).st_size == 0:
|
||||
logging.error(f"The input dataset_manifest {cfg.dataset_manifest} is empty. Exiting!")
|
||||
return None
|
||||
manifest_dir = Path(cfg.dataset_manifest).parent
|
||||
with open(cfg.dataset_manifest, 'r') as f:
|
||||
for line in f:
|
||||
item = json.loads(line)
|
||||
audio_file = Path(item['audio_filepath'])
|
||||
if not audio_file.is_file() and not audio_file.is_absolute():
|
||||
audio_file = manifest_dir / audio_file
|
||||
filepaths.append(str(audio_file.absolute()))
|
||||
reference_texts.append(item['text'])
|
||||
|
||||
# do grid-based benchmarking if grid_params is provided, otherwise a regular one
|
||||
work_dir = Path(cfg.output_dir)
|
||||
os.makedirs(work_dir, exist_ok=True)
|
||||
report_legend = (
|
||||
",".join(
|
||||
[
|
||||
"model_type",
|
||||
"aggregation",
|
||||
"blank",
|
||||
"duration",
|
||||
"method_name",
|
||||
"entropy_type",
|
||||
"entropy_norm",
|
||||
"alpha",
|
||||
"target_level",
|
||||
"auc_roc",
|
||||
"auc_pr",
|
||||
"auc_nt",
|
||||
"nce",
|
||||
"ece",
|
||||
"auc_yc",
|
||||
"std_yc",
|
||||
"max_yc",
|
||||
]
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
model_typename = "RNNT" if is_rnnt else "CTC"
|
||||
report_file = work_dir / Path("report.csv")
|
||||
if cfg.grid_params:
|
||||
asr_model.change_decoding_strategy(
|
||||
RNNTDecodingConfig(fused_batch_size=-1, strategy="greedy_batch", confidence_cfg=cfg.confidence_cfg)
|
||||
if is_rnnt
|
||||
else CTCDecodingConfig(confidence_cfg=cfg.confidence_cfg)
|
||||
)
|
||||
params = json.loads(cfg.grid_params)
|
||||
hp_grid = ParameterGrid(params)
|
||||
hp_grid = list(hp_grid)
|
||||
|
||||
logging.info(f"==============================Running a benchmarking with grid search=========================")
|
||||
logging.info(f"Grid search size: {len(hp_grid)}")
|
||||
logging.info(f"Results will be written to:\nreport file `{report_file}`\nand plot directories near the file")
|
||||
logging.info(f"==============================================================================================")
|
||||
|
||||
with open(report_file, "tw", encoding="utf-8") as f:
|
||||
f.write(report_legend)
|
||||
f.flush()
|
||||
for i, hp in enumerate(hp_grid):
|
||||
logging.info(f"Run # {i + 1}, grid: `{hp}`")
|
||||
asr_model.change_decoding_strategy(apply_confidence_parameters(asr_model.cfg.decoding, hp))
|
||||
param_list, experiment_name = get_experiment_params(asr_model.cfg.decoding.confidence_cfg)
|
||||
plot_dir = work_dir / Path(experiment_name)
|
||||
results = run_confidence_benchmark(
|
||||
asr_model,
|
||||
cfg.target_level,
|
||||
filepaths,
|
||||
reference_texts,
|
||||
cfg.batch_size,
|
||||
cfg.num_workers,
|
||||
plot_dir,
|
||||
cfg.amp,
|
||||
)
|
||||
for level, result in results.items():
|
||||
f.write(f"{model_typename},{','.join(param_list)},{level},{','.join([str(r) for r in result])}\n")
|
||||
f.flush()
|
||||
else:
|
||||
asr_model.change_decoding_strategy(
|
||||
RNNTDecodingConfig(fused_batch_size=-1, strategy="greedy_batch", confidence_cfg=cfg.confidence_cfg)
|
||||
if is_rnnt
|
||||
else CTCDecodingConfig(confidence_cfg=cfg.confidence_cfg)
|
||||
)
|
||||
param_list, experiment_name = get_experiment_params(asr_model.cfg.decoding.confidence_cfg)
|
||||
plot_dir = work_dir / Path(experiment_name)
|
||||
|
||||
logging.info(f"==============================Running a single benchmarking===================================")
|
||||
logging.info(f"Results will be written to:\nreport file `{report_file}`\nand plot directory `{plot_dir}`")
|
||||
|
||||
with open(report_file, "tw", encoding="utf-8") as f:
|
||||
f.write(report_legend)
|
||||
f.flush()
|
||||
results = run_confidence_benchmark(
|
||||
asr_model,
|
||||
cfg.batch_size,
|
||||
cfg.num_workers,
|
||||
cfg.target_level,
|
||||
filepaths,
|
||||
reference_texts,
|
||||
plot_dir,
|
||||
cfg.amp,
|
||||
)
|
||||
for level, result in results.items():
|
||||
f.write(f"{model_typename},{','.join(param_list)},{level},{','.join([str(r) for r in result])}\n")
|
||||
logging.info(f"===========================================Done===============================================")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user