Files
artcraft/models/tts/NeMo/tools/ctc_segmentation/scripts/cut_audio.py
T

314 lines
13 KiB
Python

# 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')