Introduce code from Rust monorepo. This is a mess

This commit is contained in:
Brandon Thomas
2022-02-28 05:43:54 -05:00
parent 921fb49f9a
commit ba326bfcfd
1058 changed files with 192192 additions and 0 deletions
@@ -0,0 +1,313 @@
# 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
import time
from pathlib import Path
from typing import List
import torch
from scipy.io import wavfile
from nemo.collections import asr as nemo_asr
from nemo.collections.asr.metrics.wer import WER, word_error_rate
parser = argparse.ArgumentParser(description="Cut audio on the segments based on segments")
parser.add_argument("--output_dir", default='output', type=str, help='Path to output directory')
parser.add_argument(
"--alignment",
type=str,
required=True,
help='Path to a data directory with alignments or a single .txt file with timestamps - result of the ctc-segmentation',
)
parser.add_argument("--threshold", type=float, default=-5, help='Minimum score value accepted')
parser.add_argument(
'--model',
type=str,
default='QuartzNet15x5Base-En',
help='Path to model checkpoint or pre-trained CTC-based ASR model name',
)
parser.add_argument('--offset', type=int, default=0, help='Offset in seconds')
parser.add_argument("--batch_size", type=int, default=64, help='Batch size for inference')
def add_transcript_to_manifest(
manifest_original: str, manifest_updated: str, asr_model: nemo_asr.models.EncDecCTCModel, batch_size: int
) -> None:
"""
Adds transcripts generated by the asr_model to the manifest_original.
Args:
manifest_original: path to the manifest
manifest_updated: path to the updated manifest with transcript included
asr_model: CTC-based ASR model, for example, QuartzNet15x5Base-En
batch_size: Batch size for asr_model inference
"""
transcripts = get_transcript(manifest_original, asr_model, batch_size)
with open(manifest_original, 'r', encoding='utf8') as f:
with open(manifest_updated, 'w', encoding='utf8') as f_updated:
for i, line in enumerate(f):
info = json.loads(line)
info['transcript'] = transcripts[i].strip()
info['WER'] = round(word_error_rate([info['transcript']], [info['text']]) * 100, 2)
info['CER'] = round(word_error_rate([info['transcript']], [info['text']], use_cer=True) * 100, 2)
json.dump(info, f_updated, ensure_ascii=False)
f_updated.write('\n')
def get_transcript(manifest_path: str, asr_model: nemo_asr.models.EncDecCTCModel, batch_size: int) -> List[str]:
"""
Returns transcripts for audio segments in the batch
Args:
manifest_path: path to the manifest for inference
asr_model: CTC-based ASR model, for example, QuartzNet15x5Base-En
batch_size: batch size
Returns: hypotheses: transcripts for the audio segments
"""
# batch inference
try:
from torch.cuda.amp import autocast
except ImportError:
from contextlib import contextmanager
@contextmanager
def autocast(enabled=None):
yield
torch.set_grad_enabled(False)
asr_model.setup_test_data(
test_data_config={
'sample_rate': 16000,
'manifest_filepath': manifest_path,
'labels': asr_model.decoder.vocabulary,
'batch_size': batch_size,
'normalize_transcripts': False,
}
)
asr_model.eval()
wer = WER(vocabulary=asr_model.decoder.vocabulary)
hypotheses = []
for test_batch in asr_model.test_dataloader():
if torch.cuda.is_available():
test_batch = [x.cuda() for x in test_batch]
with autocast():
log_probs, encoded_len, greedy_predictions = asr_model(
input_signal=test_batch[0], input_signal_length=test_batch[1]
)
hypotheses += wer.ctc_decoder_predictions_tensor(greedy_predictions)
del test_batch
torch.cuda.empty_cache()
return hypotheses
def process_alignment(alignment_file: str, args):
""" Cut original audio file into audio segments based on alignment_file
Args:
alignment_file: path to the file with segmented text and corresponding time stamps.
The first line of the file contains the path to the original audio file
args: main script args
"""
if not os.path.exists(alignment_file):
raise ValueError(f'{alignment_file} not found')
# read the segments, note the first line contains the path to the original audio
segments = []
ref_text_processed = []
ref_text_no_preprocessing = []
with open(alignment_file, 'r') as f:
for line in f:
line = line.split('|')
# read audio file name from the first line
if len(line) == 1:
audio_file = line[0].strip()
continue
ref_text_processed.append(line[1].strip())
ref_text_no_preprocessing.append(line[2].strip())
line = line[0].split()
segments.append((float(line[0]) + args.offset / 1000, float(line[1]) + args.offset / 1000, float(line[2])))
# cut the audio into segments
# create manifest in /tmp directory first, then populate transcript values with the batch inference results
# and save the final manifests at output_dir
sampling_rate, signal = wavfile.read(audio_file)
original_duration = len(signal) / sampling_rate
print(f'Cutting {audio_file} based on {alignment_file}')
print(f'Original duration: {round(original_duration)}s or ~{round(original_duration / 60)}min')
# create directories to store high score .wav audio fragments, low scored once, and deleted
args.output_dir = os.path.abspath(args.output_dir)
os.makedirs(args.output_dir, exist_ok=True)
fragments_dir = os.path.join(args.output_dir, "high_score_clips")
del_fragments = os.path.join(args.output_dir, 'deleted_clips')
low_score_segments_dir = os.path.join(args.output_dir, "low_score_clips")
os.makedirs(fragments_dir, exist_ok=True)
os.makedirs(low_score_segments_dir, exist_ok=True)
os.makedirs(del_fragments, exist_ok=True)
base_name = os.path.basename(alignment_file).replace('_segments.txt', '')
high_score_manifest = f'{base_name}_high_score_manifest.json'
low_score_manifest = f'{base_name}_low_score_manifest.json'
del_manifest = f'{base_name}_del_manifest.json'
manifests_dir = os.path.join(args.output_dir, 'manifests')
os.makedirs(manifests_dir, exist_ok=True)
tmp_dir = '/tmp'
low_score_dur = 0
high_score_dur = 0
with open(os.path.join(tmp_dir, high_score_manifest), 'w', encoding='utf8') as f:
with open(os.path.join(tmp_dir, low_score_manifest), 'w', encoding='utf8') as low_score_f:
for i, (st, end, score) in enumerate(segments):
segment = signal[round(st * sampling_rate) : round(end * sampling_rate)]
duration = len(segment) / sampling_rate
if duration > 0:
text_processed = ref_text_processed[i].strip()
text_no_preprocessing = ref_text_no_preprocessing[i].strip()
if score > args.threshold:
high_score_dur += duration
audio_filepath = os.path.join(fragments_dir, f'{base_name}_{i:04}.wav')
file_to_write = f
else:
low_score_dur += duration
audio_filepath = os.path.join(low_score_segments_dir, f'{base_name}_{i:04}.wav')
file_to_write = low_score_f
wavfile.write(audio_filepath, sampling_rate, segment)
transcript = 'n/a' # asr_model.transcribe(paths2audio_files=[audio_filepath], batch_size=1)[0]
info = {
'audio_filepath': audio_filepath,
'duration': duration,
'text': text_processed,
'text_no_preprocessing': text_no_preprocessing,
'score': round(score, 2),
'transcript': transcript.strip(),
}
json.dump(info, file_to_write, ensure_ascii=False)
file_to_write.write('\n')
add_transcript_to_manifest(
os.path.join(tmp_dir, high_score_manifest),
os.path.join(manifests_dir, high_score_manifest),
asr_model,
args.batch_size,
)
add_transcript_to_manifest(
os.path.join(tmp_dir, low_score_manifest),
os.path.join(manifests_dir, low_score_manifest),
asr_model,
args.batch_size,
)
print(f'High score files duration: {round(high_score_dur)}s or ~{round(high_score_dur/60)}min at {manifests_dir}')
print(
f'Low score files duration: {round(low_score_dur)}s or ~{round(low_score_dur/60)}min saved at {manifests_dir}'
)
# save deleted segments along with manifest
deleted = []
del_duration = 0
begin = 0
i = 0
with open(os.path.join(manifests_dir, del_manifest), 'w', encoding='utf8') as f:
for i, (st, end, _) in enumerate(segments):
if st - begin > 0.01:
segment = signal[int(begin * sampling_rate) : int(st * sampling_rate)]
audio_filepath = os.path.join(del_fragments, f'del_{base_name}_{i:04}.wav')
wavfile.write(audio_filepath, sampling_rate, segment)
duration = len(segment) / sampling_rate
del_duration += duration
deleted.append((begin, st))
transcript = 'n/a' # asr_model.transcribe(paths2audio_files=[audio_filepath], batch_size=1)[0]
info = {'audio_filepath': audio_filepath, 'duration': duration, 'text': transcript}
json.dump(info, f, ensure_ascii=False)
f.write('\n')
begin = end
segment = signal[int(begin * sampling_rate) :]
audio_filepath = os.path.join(del_fragments, f'del_{base_name}_{i+1:04}.wav')
wavfile.write(audio_filepath, sampling_rate, segment)
duration = len(segment) / sampling_rate
del_duration += duration
deleted.append((begin, original_duration))
info = {'audio_filepath': audio_filepath, 'duration': duration, 'text': 'n/a'}
json.dump(info, f)
f.write('\n')
print(f'Saved DEL files duration: {round(del_duration)}s or ~ {round(del_duration/60)}min at {del_fragments}')
missing_audio = original_duration - high_score_dur - del_duration - low_score_dur
if missing_audio > 15:
raise ValueError(f'{round(missing_audio)}s or ~ {round(missing_audio/60)}min is missing. Check the args')
stats = (
args.output_dir,
base_name,
round(original_duration),
round(high_score_dur),
round(low_score_dur),
round(del_duration),
)
return stats
if __name__ == '__main__':
start_time = time.time()
args = parser.parse_args()
os.makedirs(args.output_dir, exist_ok=True)
if os.path.exists(args.model):
asr_model = nemo_asr.models.EncDecCTCModel.restore_from(args.model, strict=False)
elif args.model in nemo_asr.models.EncDecCTCModel.get_available_model_names():
asr_model = nemo_asr.models.EncDecCTCModel.from_pretrained(args.model, strict=False)
else:
raise ValueError(
f'Provide path to the pretrained checkpoint or choose from {nemo_asr.models.EncDecCTCModel.list_available_models()}'
)
alignment_files = Path(args.alignment)
if os.path.isdir(args.alignment):
alignment_files = alignment_files.glob("*.txt")
else:
alignment_files = [Path(alignment_files)]
stats_file = os.path.join(args.output_dir, 'stats.tsv')
with open(stats_file, 'w') as f:
f.write('Folder\tSegment\tOriginal dur (s)\tHigh quality dur (s)\tLow quality dur (s)\tDeleted dur (s)\n')
high_score_dur = 0
low_score_dur = 0
del_duration = 0
for alignment_file in alignment_files:
stats = process_alignment(alignment_file, args)
high_score_dur += stats[-3]
low_score_dur += stats[-2]
del_duration += stats[-1]
stats = '\t'.join([str(t) for t in stats]) + '\n'
f.write(stats)
f.write(f'Total\t\t{round(high_score_dur)}\t{round(low_score_dur)}\t{del_duration}')
total_time = time.time() - start_time
print(f'High score segments duration: {round(high_score_dur)}')
print(f'Low score segments duration: {round(low_score_dur)}')
print(f'Deleted segments duration: {round(del_duration)}')
print(f'Stats saved at {stats_file}')
print(f'Total execution time: ~{round(total_time / 60)}min')
@@ -0,0 +1,69 @@
# 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.
__all__ = ['LATIN_TO_RU', 'RU_ABBREVIATIONS', 'NUMBERS_TO_ENG', 'NUMBERS_TO_RU']
LATIN_TO_RU = {
'a': 'а',
'b': 'б',
'c': 'к',
'd': 'д',
'e': 'е',
'f': 'ф',
'g': 'г',
'h': 'х',
'i': 'и',
'j': 'ж',
'k': 'к',
'l': 'л',
'm': 'м',
'n': 'н',
'o': 'о',
'p': 'п',
'q': 'к',
'r': 'р',
's': 'с',
't': 'т',
'u': 'у',
'v': 'в',
'w': 'в',
'x': 'к',
'y': 'у',
'z': 'з',
'à': 'а',
'è': 'е',
'é': 'е',
'ß': 'в',
'ä': 'а',
'ö': 'о',
'ü': 'у',
'є': 'е',
'ç': 'с',
'ê': 'е',
'ó': 'о',
}
RU_ABBREVIATIONS = {
' р.': ' рублей',
' к.': ' копеек',
' коп.': ' копеек',
' копек.': ' копеек',
' т.д.': ' так далее',
' т. д.': ' так далее',
' т.п.': ' тому подобное',
' т. п.': ' тому подобное',
' т.е.': ' то есть',
' т. е.': ' то есть',
' стр. ': ' страница ',
}
@@ -0,0 +1,412 @@
# 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 multiprocessing
import os
import re
from pathlib import Path
from typing import List
import regex
import scipy.io.wavfile as wav
from normalization_helpers import LATIN_TO_RU, RU_ABBREVIATIONS
from num2words import num2words
from nemo.collections import asr as nemo_asr
try:
from nemo_text_processing.text_normalization.normalize import Normalizer
NEMO_NORMALIZATION_AVAILABLE = True
except (ModuleNotFoundError, ImportError):
NEMO_NORMALIZATION_AVAILABLE = False
parser = argparse.ArgumentParser(description="Prepares text and audio files for segmentation")
parser.add_argument("--in_text", type=str, default=None, help='Path to a text file or a directory with .txt files')
parser.add_argument("--output_dir", type=str, required=True, help='Path to output directory')
parser.add_argument("--audio_dir", type=str, help='Path to folder with .mp3 or .wav audio files')
parser.add_argument(
"--audio_format", type=str, default='.mp3', choices=['.mp3', '.wav'], help='Audio files format in --audio_dir'
)
parser.add_argument('--sample_rate', type=int, default=16000, help='Sampling rate used during ASR model training')
parser.add_argument(
'--language', type=str, default='eng', choices=['eng', 'ru', 'add other languages supported by num2words.']
)
parser.add_argument(
'--cut_prefix', type=int, default=0, help='Number of seconds to cut from the beginning of the audio files.',
)
parser.add_argument(
'--model', type=str, default='QuartzNet15x5Base-En', help='Pre-trained model name or path to model checkpoint'
)
parser.add_argument('--min_length', type=int, default=0, help='Min number of chars of the text segment for alignment.')
parser.add_argument(
'--max_length', type=int, default=100, help='Max number of chars of the text segment for alignment.'
)
parser.add_argument(
'--additional_split_symbols',
type=str,
default='',
help='Additional symbols to use for \
sentence split if eos sentence split resulted in sequence longer than --max_length. '
'Use "|" as a separator between symbols, for example: ";|:|" ',
)
parser.add_argument(
'--use_nemo_normalization',
action='store_true',
help='Set to True to use NeMo Normalization tool to convert numbers from written to spoken format.',
)
def convert_audio(in_file: str, wav_file: str = None, sample_rate: int = 16000) -> str:
"""
Convert .mp3 to .wav and/or change sample rate if needed
Args:
in_file: Path to .mp3 or .wav file
sample_rate: Desired sample rate
Returns:
path to .wav file
"""
print(f"Converting {in_file} to .wav format with sample rate {sample_rate}")
if not os.path.exists(in_file):
raise ValueError(f'{in_file} not found')
if wav_file is None:
wav_file = in_file.replace(os.path.splitext(in_file)[-1], f"_{sample_rate}.wav")
os.system(
f'ffmpeg -i {in_file} -acodec pcm_s16le -ac 1 -af aresample=resampler=soxr -ar {sample_rate} {wav_file} -y'
)
return wav_file
def process_audio(in_file: str, wav_file: str = None, cut_prefix: int = 0, sample_rate: int = 16000):
"""Process audio file: .mp3 to .wav conversion and cut a few seconds from the beginning of the audio
Args:
in_file: path to the .mp3 or .wav file for processing
wav_file: path to the output .wav file
cut_prefix: number of seconds to cut from the beginning of the audio file
sample_rate: target sampling rate
"""
wav_audio = convert_audio(str(in_file), wav_file, sample_rate)
if cut_prefix > 0:
# cut a few seconds of audio from the beginning
sample_rate, signal = wav.read(wav_audio)
wav.write(wav_audio, data=signal[cut_prefix * sample_rate :], rate=sample_rate)
def split_text(
in_file: str,
out_file: str,
vocabulary: List[str] = None,
language='eng',
remove_brackets=True,
do_lower_case=True,
min_length=0,
max_length=100,
additional_split_symbols=None,
use_nemo_normalization=False,
):
"""
Breaks down the in_file roughly into sentences. Each sentence will be on a separate line.
Written form of the numbers will be converted to its spoken equivalent, OOV punctuation will be removed.
Args:
in_file: path to original transcript
out_file: path to the output file
vocabulary: ASR model vocabulary
language: text language
remove_brackets: Set to True if square [] and curly {} brackets should be removed from text.
Text in square/curly brackets often contains inaudible fragments like notes or translations
do_lower_case: flag that determines whether to apply lower case to the in_file text
min_length: Min number of chars of the text segment for alignment. Short segments will be combined to be
at least min_length (not recommended for multi speaker data).
max_length: Max number of chars of the text segment for alignment
additional_split_symbols: Additional symbols to use for sentence split if eos sentence split resulted in
segments longer than --max_length
use_nemo_normalization: Set to True to use NeMo normalization tool to convert numbers from written to spoken
format. Normalization using num2words will be applied afterwards to make sure there are no numbers present
in the text, otherwise they will be replaced with a space and that could deteriorate segmentation results.
"""
print(f'Splitting text in {in_file} into sentences.')
with open(in_file, "r") as f:
transcript = f.read()
# remove some symbols for better split into sentences
transcript = (
transcript.replace("\n", " ")
.replace("\t", " ")
.replace("…", "...")
.replace("\\", " ")
.replace("--", " -- ")
.replace(". . .", "...")
.replace("‘", "’")
)
# remove extra space
transcript = re.sub(r' +', ' ', transcript)
transcript = re.sub(r'(\.+)', '. ', transcript)
if remove_brackets:
transcript = re.sub(r'(\[.*?\])', ' ', transcript)
# remove text in curly brackets
transcript = re.sub(r'(\{.*?\})', ' ', transcript)
lower_case_unicode = ''
upper_case_unicode = ''
if language == 'ru':
lower_case_unicode = '\u0430-\u04FF'
upper_case_unicode = '\u0410-\u042F'
elif language not in ['ru', 'eng']:
print(f'Consider using {language} unicode letters for better sentence split.')
# remove space in the middle of the lower case abbreviation to avoid splitting into separate sentences
matches = re.findall(r'[a-z' + lower_case_unicode + ']\.\s[a-z' + lower_case_unicode + ']\.', transcript)
for match in matches:
transcript = transcript.replace(match, match.replace('. ', '.'))
# find phrases in quotes
with_quotes = re.finditer(r'“[A-Za-z ?]+.*?”', transcript)
sentences = []
last_idx = 0
for m in with_quotes:
match = m.group()
match_idx = m.start()
if last_idx < match_idx:
sentences.append(transcript[last_idx:match_idx])
sentences.append(match)
last_idx = m.end()
sentences.append(transcript[last_idx:])
sentences = [s.strip() for s in sentences if s.strip()]
# Read and split transcript by utterance (roughly, sentences)
split_pattern = f"(?<!\w\.\w.)(?<![A-Z{upper_case_unicode}][a-z{lower_case_unicode}]\.)(?<![A-Z{upper_case_unicode}]\.)(?<=\.|\?|\!|\.”|\?”\!”)\s"
new_sentences = []
for sent in sentences:
new_sentences.extend(regex.split(split_pattern, sent))
sentences = [s.strip() for s in new_sentences if s.strip()]
def additional_split(sentences, split_on_symbols, max_length):
if len(split_on_symbols) == 0:
return sentences
split_on_symbols = split_on_symbols.split('|')
def _split(sentences, delimiter, max_length):
result = []
for s in sentences:
if len(s) <= max_length:
result.append(s)
else:
split_sent = s.split(delimiter)
result.extend([s + delimiter for s in split_sent[:-1]] + [split_sent[-1]])
return result
another_sent_split = []
for sent in sentences:
split_sent = [sent]
for delimiter in split_on_symbols:
split_sent = _split(split_sent, delimiter + ' ', max_length)
another_sent_split.extend(split_sent)
sentences = [s.strip() for s in another_sent_split if s.strip()]
return sentences
sentences = additional_split(sentences, additional_split_symbols, max_length)
# check to make sure there will be no utterances for segmentation with only OOV symbols
vocab_no_space_with_digits = set(vocabulary + [i for i in range(10)])
vocab_no_space_with_digits.remove(' ')
sentences = [s for s in sentences if len(vocab_no_space_with_digits.intersection(set(s))) > 0]
if min_length > 0:
sentences_comb = []
sentences_comb.append(sentences[0])
# combines short sentence
for i in range(1, len(sentences)):
if len(sentences_comb[-1]) < min_length or len(sentences[i]) < min_length:
sentences_comb[-1] += ' ' + sentences[i].strip()
else:
sentences_comb.append(sentences[i].strip())
sentences = sentences_comb
sentences = [s.strip() for s in sentences if s.strip()]
# save split text with original punctuation and case
out_dir, out_file_name = os.path.split(out_file)
with open(os.path.join(out_dir, out_file_name[:-4] + '_with_punct.txt'), "w") as f:
f.write("\n".join(sentences))
# substitute common abbreviations before applying lower case
if language == 'ru':
for k, v in RU_ABBREVIATIONS.items():
sentences = [s.replace(k, v) for s in sentences]
if language == 'ru':
# replace Latin characters with Russian
for k, v in LATIN_TO_RU.items():
sentences = [s.replace(k, v) for s in sentences]
if language == 'eng' and use_nemo_normalization:
if not NEMO_NORMALIZATION_AVAILABLE:
raise ValueError(f'NeMo normalization tool is not installed.')
print('Using NeMo normalization tool...')
normalizer = Normalizer(input_case='cased')
sentences_norm = normalizer.normalize_list(sentences, verbose=False)
if len(sentences_norm) != len(sentences):
raise ValueError(f'Normalization failed, number of sentences does not match.')
sentences = '\n'.join(sentences)
# replace numbers with num2words
try:
p = re.compile("\d+")
new_text = ''
match_end = 0
for i, m in enumerate(p.finditer(sentences)):
match = m.group()
match_start = m.start()
if i == 0:
new_text = sentences[:match_start]
else:
new_text += sentences[match_end:match_start]
match_end = m.end()
new_text += sentences[match_start:match_end].replace(match, num2words(match, lang=language))
new_text += sentences[match_end:]
sentences = new_text
except NotImplementedError:
print(
f'{language} might be missing in "num2words" package. Add required language to the choices for the'
f'--language argument.'
)
raise
sentences = (
sentences.replace("’", "'")
.replace("»", '"')
.replace("«", '"')
.replace("\\", "")
.replace("”", '"')
.replace("„", '"')
.replace("´", "'")
.replace("-- --", "--")
.replace("--", " -- ")
.replace("’", "'")
.replace('“', '"')
.replace('“', '"')
.replace("‘", "'")
.replace('—', '-')
.replace("- -", "--")
.replace('`', "'")
.replace(' !', '!')
.replace(' ?', '?')
.replace(' ,', ',')
.replace(' .', '.')
.replace(' ;', ';')
.replace(' :', ':')
.replace('!!', '!')
.replace('--', '-')
.replace('“', '"')
.replace(', , ', ', ')
.replace('=', '')
)
allowed_punct = [',', '.', '?', '!', ':', ';', '-', '"', '(', ')']
# clean up normalized text and keep only allowed_punct and ASR vocabulary (lower and upper case)
symbols_to_remove = ''.join(
set(sentences).difference(set(vocabulary + [s.upper() for s in vocabulary] + ['\n'] + allowed_punct))
)
sentences_norm = sentences.translate(''.maketrans(symbols_to_remove, len(symbols_to_remove) * ' '))
with open(os.path.join(out_dir, out_file_name[:-4] + '_with_punct_normalized.txt'), "w") as f:
f.write(sentences_norm)
if do_lower_case:
sentences = sentences.lower()
# remove all OOV symbols
symbols_to_remove = ''.join(set(sentences).difference(set(vocabulary + ['\n'])))
sentences = sentences.translate(''.maketrans(symbols_to_remove, len(symbols_to_remove) * ' '))
# remove extra space
sentences = re.sub(r' +', ' ', sentences)
with open(out_file, "w") as f:
f.write(sentences)
if __name__ == '__main__':
args = parser.parse_args()
os.makedirs(args.output_dir, exist_ok=True)
text_files = []
if args.in_text:
vocabulary = None
if args.model is None:
print(f"No model provided, vocabulary won't be used")
elif os.path.exists(args.model):
asr_model = nemo_asr.models.EncDecCTCModel.restore_from(args.model)
vocabulary = asr_model.cfg.decoder.vocabulary
elif args.model in nemo_asr.models.EncDecCTCModel.get_available_model_names():
asr_model = nemo_asr.models.EncDecCTCModel.from_pretrained(args.model)
vocabulary = asr_model.cfg.decoder.vocabulary
else:
raise ValueError(
f'Provide path to the pretrained checkpoint or choose from {nemo_asr.models.EncDecCTCModel.get_available_model_names()}'
)
if os.path.isdir(args.in_text):
text_files = Path(args.in_text).glob(("*.txt"))
else:
text_files.append(Path(args.in_text))
for text in text_files:
base_name = os.path.basename(text)[:-4]
out_text_file = os.path.join(args.output_dir, base_name + '.txt')
split_text(
text,
out_text_file,
vocabulary=vocabulary,
language=args.language,
min_length=args.min_length,
max_length=args.max_length,
additional_split_symbols=args.additional_split_symbols,
use_nemo_normalization=args.use_nemo_normalization,
)
print(f'Processed text saved at {args.output_dir}')
if args.audio_dir:
if not os.path.exists(args.audio_dir):
raise ValueError(f'{args.audio_dir} not found. "--audio_dir" should contain .mp3 or .wav files.')
audio_paths = list(Path(args.audio_dir).glob(f"*{args.audio_format}"))
workers = []
for i in range(len(audio_paths)):
wav_file = os.path.join(args.output_dir, audio_paths[i].name.replace(args.audio_format, ".wav"))
worker = multiprocessing.Process(
target=process_audio, args=(audio_paths[i], wav_file, args.cut_prefix, args.sample_rate),
)
workers.append(worker)
worker.start()
for w in workers:
w.join()
print('Done.')
@@ -0,0 +1,111 @@
# 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
from pathlib import Path
from random import randint
from typing import List
parser = argparse.ArgumentParser(description="Combine manifests and extract samples")
parser.add_argument(
"--manifests_dir",
default='_high_score_manifest.json',
type=str,
required=True,
help='Path to directory with *_high_score_manifest.json files',
)
parser.add_argument(
"--output_dir", default='output', required=True, help='Path to the directory to store final manifest'
)
parser.add_argument(
"--num_samples",
default=0,
type=int,
help='The number of samples to get from the beginning, end and randomly from the middle of highly scored samples',
)
def get_samples(manifest: str, num_samples: int) -> List[str]:
"""
Samples num_samples from the beginning, end and randomly from the middle of the manifest
Args:
manifest: path to manifest
num_samples: number of samples to extract from the beginning, end and randomly from the middle of the manifest
Returns:
num_samples: manifest lines
"""
samples = []
with open(manifest, 'r', encoding='utf8') as f:
lines = f.readlines()
# first n samples
samples.extend(lines[:num_samples])
# random in the middle
for i in range(num_samples):
samples.append(lines[randint(num_samples, len(lines) - num_samples)])
# last n samples
samples.extend(lines[-num_samples:])
return samples
if __name__ == '__main__':
args = parser.parse_args()
manifest_dir = Path(args.manifests_dir)
manifests = sorted(list(manifest_dir.glob('*_high_score_manifest.json')))
sample_manifest = os.path.join(args.output_dir, 'sample_manifest.json')
all_manifest = os.path.join(args.output_dir, 'all_manifest.json')
if args.num_samples > 0:
with open(sample_manifest, 'w', encoding='utf8') as f:
for manifest in manifests:
samples = get_samples(manifest, args.num_samples)
for sample in samples:
f.write(sample)
print(f'Sample manifest is saved at {sample_manifest}')
total_duration = 0
with open(all_manifest, 'w') as out_f:
for manifest in manifests:
with open(manifest, 'r', encoding='utf8') as f:
for line in f:
info = json.loads(line)
total_duration += info['duration']
out_f.write(line)
print(f'Aggregated manifest is saved at {all_manifest}')
print(f'Total files duration: ~{round(total_duration/60)} min or ~{round(total_duration/60/60)} hr')
sets = {'_high_score_manifest.json': 0, '_low_score_manifest.json': 0, '_del_manifest.json': 0}
for k in sets:
files = manifest_dir.glob(f'*{k}')
for manifest_f in files:
with open(manifest_f, 'r') as f:
for line in f:
line = json.loads(line)
sets[k] += line['duration']
total = 0
for k in sets:
sets[k] = round(sets[k] / 3600, 1)
total += sets[k]
name = k.split('_')[1].upper()
print(f'Total {name} score duration: ~{sets[k]} hr')
print(f'Total duration: ~{total} hr')
print('Done.')
@@ -0,0 +1,180 @@
# 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 logging
import multiprocessing
import os
import sys
import time
from pathlib import Path
import numpy as np
import scipy.io.wavfile as wav
import torch
from utils import get_segments, listener_configurer, listener_process, worker_configurer, worker_process
import nemo.collections.asr as nemo_asr
parser = argparse.ArgumentParser(description="CTC Segmentation")
parser.add_argument("--output_dir", default='output', type=str, help='Path to output directory')
parser.add_argument(
"--data",
type=str,
required=True,
help='Path to directory with audio files and associated transcripts (same respective names only formats are '
'different or path to wav file (transcript should have the same base name and be located in the same folder'
'as the wav file.',
)
parser.add_argument('--window_len', type=int, default=8000, help='Window size for ctc segmentation algorithm')
parser.add_argument('--no_parallel', action='store_true', help='Flag to disable parallel segmentation')
parser.add_argument('--sample_rate', type=int, default=16000, help='Sampling rate')
parser.add_argument(
'--model', type=str, default='QuartzNet15x5Base-En', help='Path to model checkpoint or pre-trained model name',
)
parser.add_argument('--debug', action='store_true', help='Flag to enable debugging messages')
logger = logging.getLogger('ctc_segmentation') # use module name
if __name__ == '__main__':
args = parser.parse_args()
# setup logger
log_dir = os.path.join(args.output_dir, 'logs')
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, f'ctc_segmentation_{args.window_len}.log')
level = 'DEBUG' if args.debug else 'INFO'
logger = logging.getLogger('CTC')
file_handler = logging.FileHandler(filename=log_file)
stdout_handler = logging.StreamHandler(sys.stdout)
handlers = [file_handler, stdout_handler]
logging.basicConfig(handlers=handlers, level=level)
if os.path.exists(args.model):
asr_model = nemo_asr.models.EncDecCTCModel.restore_from(args.model)
elif args.model in nemo_asr.models.EncDecCTCModel.get_available_model_names():
asr_model = nemo_asr.models.EncDecCTCModel.from_pretrained(args.model, strict=False)
else:
raise ValueError(
f'{args.model} not a valid model name or path. Provide path to the pre-trained checkpoint '
f'or choose from {nemo_asr.models.EncDecCTCModel.list_available_models()}'
)
# extract ASR vocabulary and add blank symbol
vocabulary = asr_model.cfg.decoder.vocabulary
odim = len(vocabulary) + 1
logging.debug(f'ASR Model vocabulary: {vocabulary}')
# add blank to vocab
vocabulary = ["ε"] + list(vocabulary)
data = Path(args.data)
output_dir = Path(args.output_dir)
if os.path.isdir(data):
audio_paths = data.glob("*.wav")
data_dir = data
else:
audio_paths = [Path(data)]
data_dir = Path(os.path.dirname(data))
all_log_probs = []
all_transcript_file = []
all_segment_file = []
all_wav_paths = []
segments_dir = os.path.join(args.output_dir, 'segments')
os.makedirs(segments_dir, exist_ok=True)
for path_audio in audio_paths:
transcript_file = os.path.join(data_dir, path_audio.name.replace(".wav", ".txt"))
segment_file = os.path.join(
segments_dir, f"{args.window_len}_" + path_audio.name.replace(".wav", "_segments.txt")
)
try:
sample_rate, signal = wav.read(path_audio)
if sample_rate != args.sample_rate:
raise ValueError(
f'Sampling rate of the audio file {path_audio} doesn\'t match --sample_rate={args.sample_rate}'
)
except ValueError:
logging.error(
f"{path_audio} should be a .wav mono file with the sampling rate used for the ASR model training"
f"specified with {args.sample_rate}."
)
raise
original_duration = len(signal) / sample_rate
logging.debug(f'Duration: {original_duration}s, file_name: {path_audio}')
log_probs = asr_model.transcribe(paths2audio_files=[str(path_audio)], batch_size=1, logprobs=True)[0]
# move blank values to the first column
blank_col = log_probs[:, -1].reshape((log_probs.shape[0], 1))
log_probs = np.concatenate((blank_col, log_probs[:, :-1]), axis=1)
all_log_probs.append(log_probs)
all_segment_file.append(str(segment_file))
all_transcript_file.append(str(transcript_file))
all_wav_paths.append(path_audio)
del asr_model
torch.cuda.empty_cache()
if len(all_log_probs) == 0:
raise ValueError(f'No valid audio files found at {args.data}')
start_time = time.time()
if args.no_parallel:
for i in range(len(all_log_probs)):
get_segments(
all_log_probs[i],
all_wav_paths[i],
all_transcript_file[i],
all_segment_file[i],
vocabulary,
args.window_len,
)
else:
queue = multiprocessing.Queue(-1)
listener = multiprocessing.Process(target=listener_process, args=(queue, listener_configurer, log_file, level))
listener.start()
workers = []
for i in range(len(all_log_probs)):
worker = multiprocessing.Process(
target=worker_process,
args=(
queue,
worker_configurer,
level,
all_log_probs[i],
all_wav_paths[i],
all_transcript_file[i],
all_segment_file[i],
vocabulary,
args.window_len,
),
)
workers.append(worker)
worker.start()
for w in workers:
w.join()
queue.put_nowait(None)
listener.join()
total_time = time.time() - start_time
logger.info(f'Total execution time: ~{round(total_time/60)}min')
logger.info(f'Saving logs to {log_file}')
if os.path.exists(log_file):
with open(log_file, 'r') as f:
lines = f.readlines()
@@ -0,0 +1,173 @@
# 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 logging
import logging.handlers
import multiprocessing
import os
from pathlib import PosixPath
from typing import List, Tuple, Union
import ctc_segmentation as cs
import numpy as np
def get_segments(
log_probs: np.ndarray,
path_wav: Union[PosixPath, str],
transcript_file: Union[PosixPath, str],
output_file: str,
vocabulary: List[str],
window_size: int = 8000,
frame_duration_ms: int = 20,
) -> None:
"""
Segments the audio into segments and saves segments timings to a file
Args:
log_probs: Log probabilities for the original audio from an ASR model, shape T * |vocabulary|.
values for blank should be at position 0
path_wav: path to the audio .wav file
transcript_file: path to
output_file: path to the file to save timings for segments
vocabulary: vocabulary used to train the ASR model, note blank is at position 0
window_size: the length of each utterance (in terms of frames of the CTC outputs) fits into that window.
frame_duration_ms: frame duration in ms
"""
config = cs.CtcSegmentationParameters()
config.char_list = vocabulary
config.min_window_size = window_size
config.frame_duration_ms = frame_duration_ms
config.blank = config.space
config.subsampling_factor = 2
with open(transcript_file, "r") as f:
text = f.readlines()
text = [t.strip() for t in text if t.strip()]
# add corresponding original text without pre-processing
transcript_file_no_preprocessing = transcript_file.replace('.txt', '_with_punct.txt')
if not os.path.exists(transcript_file_no_preprocessing):
raise ValueError(f'{transcript_file_no_preprocessing} not found.')
with open(transcript_file_no_preprocessing, "r") as f:
text_no_preprocessing = f.readlines()
text_no_preprocessing = [t.strip() for t in text_no_preprocessing if t.strip()]
# add corresponding normalized original text
transcript_file_normalized = transcript_file.replace('.txt', '_with_punct_normalized.txt')
if not os.path.exists(transcript_file_normalized):
raise ValueError(f'{transcript_file_normalized} not found.')
with open(transcript_file_normalized, "r") as f:
text_normalized = f.readlines()
text_normalized = [t.strip() for t in text_normalized if t.strip()]
if len(text_no_preprocessing) != len(text):
raise ValueError(f'{transcript_file} and {transcript_file_no_preprocessing} do not match')
if len(text_normalized) != len(text):
raise ValueError(f'{transcript_file} and {transcript_file_normalized} do not match')
ground_truth_mat, utt_begin_indices = cs.prepare_text(config, text)
logging.debug(f"Syncing {transcript_file}")
logging.debug(
f"Audio length {os.path.basename(path_wav)}: {log_probs.shape[0]}. "
f"Text length {os.path.basename(transcript_file)}: {len(ground_truth_mat)}"
)
timings, char_probs, char_list = cs.ctc_segmentation(config, log_probs, ground_truth_mat)
segments = cs.determine_utterance_segments(config, utt_begin_indices, char_probs, timings, text)
write_output(output_file, path_wav, segments, text, text_no_preprocessing, text_normalized)
def write_output(
out_path: str,
path_wav: str,
segments: List[Tuple[float]],
text: str,
text_no_preprocessing: str,
text_normalized: str,
stride: int = 2,
):
"""
Write the segmentation output to a file
out_path: Path to output file
path_wav: Path to the original audio file
segments: Segments include start, end and alignment score
text: Text used for alignment
text_no_preprocessing: Reference txt without any pre-processing
text_normalized: Reference text normalized
stride: Stride applied to an ASR input
"""
# Uses char-wise alignments to get utterance-wise alignments and writes them into the given file
with open(str(out_path), "w") as outfile:
outfile.write(str(path_wav) + "\n")
for i, (start, end, score) in enumerate(segments):
outfile.write(
f'{start/stride} {end/stride} {score} | {text[i]} | {text_no_preprocessing[i]} | {text_normalized[i]}\n'
)
#####################
# logging utils
#####################
def listener_configurer(log_file, level):
root = logging.getLogger()
h = logging.handlers.RotatingFileHandler(log_file, 'w')
f = logging.Formatter('%(asctime)s %(processName)-10s %(name)s %(levelname)-8s %(message)s')
h.setFormatter(f)
ch = logging.StreamHandler()
root.addHandler(h)
root.setLevel(level)
root.addHandler(ch)
def listener_process(queue, configurer, log_file, level):
configurer(log_file, level)
while True:
try:
record = queue.get()
if record is None: # We send this as a sentinel to tell the listener to quit.
break
logger = logging.getLogger(record.name)
logger.setLevel(logging.INFO)
logger.handle(record) # No level or filter logic applied - just do it!
except Exception:
import sys
import traceback
print('Problem:', file=sys.stderr)
traceback.print_exc(file=sys.stderr)
def worker_configurer(queue, level):
h = logging.handlers.QueueHandler(queue) # Just the one handler needed
root = logging.getLogger()
root.addHandler(h)
root.setLevel(level)
def worker_process(
queue, configurer, level, log_probs, path_wav, transcript_file, output_file, vocabulary, window_len
):
configurer(queue, level)
name = multiprocessing.current_process().name
innerlogger = logging.getLogger('worker')
innerlogger.info(f'{name} is processing {path_wav}, window_len={window_len}')
get_segments(log_probs, path_wav, transcript_file, output_file, vocabulary, window_len)
innerlogger.info(f'{name} completed segmentation of {path_wav}, segments saved to {output_file}')
@@ -0,0 +1,143 @@
# 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 os
import re
from pathlib import Path
import pandas as pd
parser = argparse.ArgumentParser(description="Compare alignment segments generated with different window sizes")
parser.add_argument(
"--base_dir",
default='output',
type=str,
required=True,
help='Path to directory with "logs" and "segments" folders generated during the segmentation step',
)
def _process_log_file(log_file: str):
processes = {}
with open(log_file, 'r') as f:
for line in f:
if line[0].isdigit() and 'Process-' in line:
process_id = re.search(r'Process-\d+', line)[0]
window_size = re.findall(r'\d+', line)[-1]
path = re.search(r'\/.*?\.[\w:]+', line)
if 'is processing' in line:
if process_id not in processes:
processes[process_id] = {}
processes[process_id]['Window Increased'] = False
processes[process_id]['Start Window'] = window_size
processes[process_id]['Final Window'] = window_size
base_dir, base_name = os.path.split(path[0])
processes[process_id]['Audio Dir'] = base_dir
processes[process_id]['Audio File'] = base_name
elif 'Increasing' in line:
processes[process_id]['Final Window'] = window_size
processes[process_id]['Window Increased'] = True
elif 'completed' in line:
processes[process_id]['Completed'] = True
df = pd.DataFrame.from_dict(
processes,
columns=['Audio Dir', 'Audio File', 'Start Window', 'Final Window', 'Window Increased', 'Completed'],
orient='index',
).reset_index()
return df
if __name__ == '__main__':
args = parser.parse_args()
log_dir = os.path.join(args.base_dir, 'logs')
if not os.path.exists(log_dir):
raise ValueError(f'"log" directory was not found at {args.base_dir}.')
log_files = Path(log_dir).glob('*.log')
dfs = []
for log in log_files:
dfs.append(_process_log_file(log))
dfs = pd.concat(dfs)
summary_file = os.path.join(log_dir, 'log_summary.csv')
dfs.to_csv(summary_file, index=False)
print(f'Log summary saved to {summary_file}')
segments_dir = os.path.join(args.base_dir, 'segments')
if not os.path.exists(segments_dir):
raise ValueError(f'"segments" directory was not found at {args.base_dir}.')
all_files = Path(segments_dir).glob('*_segments.txt')
all_alignment_files = {}
for file in all_files:
base_name = re.sub(r'^\d+_', '', file.name)
if base_name not in all_alignment_files:
all_alignment_files[base_name] = []
all_alignment_files[base_name].append(file)
verified_dir = os.path.join(args.base_dir, 'verified_segments')
os.makedirs(verified_dir, exist_ok=True)
def readlines(file):
with open(file, 'r') as f:
lines = f.readlines()
return lines
stats = {}
for part, alignment_files in all_alignment_files.items():
stats[part] = {}
num_alignment_files = len(alignment_files)
all_alignments = []
for alignment in alignment_files:
all_alignments.append(readlines(alignment))
with open(os.path.join(verified_dir, part), 'w') as f:
num_segments = len(all_alignments[0])
stats[part]['Original number of segments'] = num_segments
stats[part]['Verified segments'] = 0
stats[part]['Original Duration, min'] = 0
stats[part]['Verified Duration, min'] = 0
for i in range(num_segments):
line = all_alignments[0][i]
valid_line = True
if i == 0:
duration = 0
else:
info = line.split('|')[0].split()
duration = (float(info[1]) - float(info[0])) / 60
stats[part]['Original Duration, min'] += duration
for alignment in all_alignments:
if line != alignment[i]:
valid_line = False
if valid_line:
f.write(line)
stats[part]['Verified segments'] += 1
stats[part]['Verified Duration, min'] += duration
stats = pd.DataFrame.from_dict(stats, orient='index').reset_index()
stats['Number dropped'] = stats['Original number of segments'] - stats['Verified segments']
stats['Duration of dropped, min'] = round(stats['Original Duration, min'] - stats['Verified Duration, min'])
stats['% dropped, min'] = round(stats['Duration of dropped, min'] / stats['Original number of segments'] * 100)
stats['Misalignment present'] = stats['Number dropped'] > 0
stats['Original Duration, min'] = round(stats['Original Duration, min'])
stats['Verified Duration, min'] = round(stats['Verified Duration, min'])
stats.loc["Total"] = stats.sum()
stats_file = os.path.join(args.base_dir, 'alignment_summary.csv')
stats.to_csv(stats_file, index=False)
print(stats)
print(f'Alignment summary saved to {stats_file}')