mirror of
https://github.com/storytold/storyteller-ml.git
synced 2026-10-17 00:23:14 +00:00
Introduce code from Rust monorepo. This is a mess
This commit is contained in:
@@ -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}')
|
||||
Reference in New Issue
Block a user