From 415129e449b04aa5a824497c15ee19f31b6b684c Mon Sep 17 00:00:00 2001 From: Michael McAuliffe Date: Thu, 16 Feb 2023 20:41:13 -0800 Subject: [PATCH] 2.2.3 (#574) * Migrate to using rich for terminal color and formatting --- environment.yml | 4 +- montreal_forced_aligner/__main__.py | 3 + montreal_forced_aligner/abc.py | 2 +- .../acoustic_modeling/base.py | 4 +- .../acoustic_modeling/lda.py | 6 +- .../acoustic_modeling/monophone.py | 4 +- .../pronunciation_probabilities.py | 4 +- .../acoustic_modeling/sat.py | 4 +- .../acoustic_modeling/trainer.py | 4 +- .../acoustic_modeling/triphone.py | 4 +- montreal_forced_aligner/alignment/adapting.py | 4 +- montreal_forced_aligner/alignment/base.py | 10 +- montreal_forced_aligner/alignment/mixins.py | 8 +- montreal_forced_aligner/command_line/adapt.py | 2 +- montreal_forced_aligner/command_line/align.py | 2 +- .../command_line/anchor.py | 8 +- .../command_line/configure.py | 2 +- .../command_line/create_segments.py | 2 +- .../command_line/diarize_speakers.py | 2 +- montreal_forced_aligner/command_line/g2p.py | 2 +- .../command_line/history.py | 11 +- montreal_forced_aligner/command_line/mfa.py | 7 +- montreal_forced_aligner/command_line/model.py | 16 +- .../command_line/tokenize.py | 2 +- .../command_line/train_acoustic_model.py | 2 +- .../command_line/train_dictionary.py | 2 +- .../command_line/train_g2p.py | 2 +- .../command_line/train_ivector_extractor.py | 2 +- .../command_line/train_lm.py | 2 +- .../command_line/train_tokenizer.py | 2 +- .../command_line/transcribe.py | 2 +- montreal_forced_aligner/command_line/utils.py | 2 +- .../command_line/validate.py | 2 +- montreal_forced_aligner/config.py | 6 +- .../corpus/acoustic_corpus.py | 20 +- montreal_forced_aligner/corpus/base.py | 8 +- .../corpus/ivector_corpus.py | 10 +- montreal_forced_aligner/corpus/text_corpus.py | 6 +- .../diarization/speaker_diarizer.py | 28 +- .../dictionary/multispeaker.py | 4 +- montreal_forced_aligner/exceptions.py | 128 +++--- montreal_forced_aligner/g2p/generator.py | 6 +- .../g2p/phonetisaurus_trainer.py | 20 +- montreal_forced_aligner/g2p/trainer.py | 4 +- montreal_forced_aligner/helper.py | 434 +----------------- montreal_forced_aligner/ivector/trainer.py | 16 +- .../language_modeling/trainer.py | 8 +- montreal_forced_aligner/models.py | 79 +--- .../tokenization/tokenizer.py | 12 +- .../tokenization/trainer.py | 4 +- .../transcription/transcriber.py | 28 +- montreal_forced_aligner/vad/segmenter.py | 6 +- .../validation/corpus_validator.py | 245 ++++------ requirements.txt | 4 +- rtd_environment.yml | 4 +- setup.cfg | 5 +- tests/conftest.py | 21 + tests/data/lab/common_voice_ja_24511055.lab | 1 + tests/data/wav/common_voice_ja_24511055.mp3 | Bin 0 -> 39141 bytes tests/test_commandline_tokenize.py | 4 +- 60 files changed, 369 insertions(+), 877 deletions(-) create mode 100644 tests/data/lab/common_voice_ja_24511055.lab create mode 100644 tests/data/wav/common_voice_ja_24511055.mp3 diff --git a/environment.yml b/environment.yml index dbda5d43..cdfb9b1a 100644 --- a/environment.yml +++ b/environment.yml @@ -8,8 +8,6 @@ dependencies: - librosa - tqdm - requests - - colorama - - ansiwrap - pyyaml - dataclassy - kaldi=*=*cpu* @@ -42,6 +40,8 @@ dependencies: - matplotlib - seaborn - pip + - rich + - rich-click - pip: - build - twine diff --git a/montreal_forced_aligner/__main__.py b/montreal_forced_aligner/__main__.py index e6e7a6fb..01328e9d 100644 --- a/montreal_forced_aligner/__main__.py +++ b/montreal_forced_aligner/__main__.py @@ -1,3 +1,6 @@ +from rich.traceback import install + from montreal_forced_aligner.command_line.mfa import mfa_cli +install(show_locals=True) mfa_cli() diff --git a/montreal_forced_aligner/abc.py b/montreal_forced_aligner/abc.py index 094a3a26..b2b2dd56 100644 --- a/montreal_forced_aligner/abc.py +++ b/montreal_forced_aligner/abc.py @@ -699,7 +699,7 @@ def check_previous_run(self) -> bool: return True conf = load_configuration(self.worker_config_path) clean = self._validate_previous_configuration(conf) - if not clean: + if not GLOBAL_CONFIG.current_profile.clean and not clean: logger.warning( "The previous run had a different configuration than the current, which may cause issues." " Please see the log for details or use --clean flag if issues are encountered." diff --git a/montreal_forced_aligner/acoustic_modeling/base.py b/montreal_forced_aligner/acoustic_modeling/base.py index a15041bb..94ef9223 100644 --- a/montreal_forced_aligner/acoustic_modeling/base.py +++ b/montreal_forced_aligner/acoustic_modeling/base.py @@ -13,8 +13,8 @@ from typing import TYPE_CHECKING, List import sqlalchemy.engine -import tqdm from sqlalchemy.orm import Session +from tqdm.rich import tqdm from montreal_forced_aligner.abc import MfaWorker, ModelExporterMixin, TrainerMixin from montreal_forced_aligner.alignment import AlignMixin @@ -327,7 +327,7 @@ def acc_stats(self) -> None: """ logger.info("Accumulating statistics...") arguments = self.acc_stats_arguments() - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() diff --git a/montreal_forced_aligner/acoustic_modeling/lda.py b/montreal_forced_aligner/acoustic_modeling/lda.py index 21f0be15..168c250c 100644 --- a/montreal_forced_aligner/acoustic_modeling/lda.py +++ b/montreal_forced_aligner/acoustic_modeling/lda.py @@ -12,7 +12,7 @@ from queue import Empty from typing import TYPE_CHECKING, Dict, List -import tqdm +from tqdm.rich import tqdm from montreal_forced_aligner.abc import KaldiFunction from montreal_forced_aligner.acoustic_modeling.triphone import TriphoneTrainer @@ -412,7 +412,7 @@ def lda_acc_stats(self) -> None: if os.path.exists(worker_lda_path): os.remove(worker_lda_path) arguments = self.lda_acc_stats_arguments() - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() @@ -509,7 +509,7 @@ def calc_lda_mllt(self) -> None: """ logger.info("Re-calculating LDA...") arguments = self.calc_lda_mllt_arguments() - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() diff --git a/montreal_forced_aligner/acoustic_modeling/monophone.py b/montreal_forced_aligner/acoustic_modeling/monophone.py index 44185a22..0d0dbbba 100644 --- a/montreal_forced_aligner/acoustic_modeling/monophone.py +++ b/montreal_forced_aligner/acoustic_modeling/monophone.py @@ -10,8 +10,8 @@ from pathlib import Path from queue import Empty -import tqdm from sqlalchemy.orm import Session, joinedload, subqueryload +from tqdm.rich import tqdm from montreal_forced_aligner.abc import KaldiFunction from montreal_forced_aligner.acoustic_modeling.base import AcousticModelTrainingMixin @@ -240,7 +240,7 @@ def mono_align_equal(self) -> None: logger.info("Generating initial alignments...") arguments = self.mono_align_equal_arguments() - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() diff --git a/montreal_forced_aligner/acoustic_modeling/pronunciation_probabilities.py b/montreal_forced_aligner/acoustic_modeling/pronunciation_probabilities.py index 845ff702..3093001a 100644 --- a/montreal_forced_aligner/acoustic_modeling/pronunciation_probabilities.py +++ b/montreal_forced_aligner/acoustic_modeling/pronunciation_probabilities.py @@ -8,8 +8,8 @@ import typing from pathlib import Path -import tqdm from sqlalchemy.orm import joinedload +from tqdm.rich import tqdm from montreal_forced_aligner.acoustic_modeling.base import AcousticModelTrainingMixin from montreal_forced_aligner.alignment.multiprocessing import ( @@ -186,7 +186,7 @@ def train_g2p_lexicon(self) -> None: ) for x in self.worker.dictionary_lookup.values() } - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: for dict_id, utt_id, phones in run_kaldi_function( GeneratePronunciationsFunction, arguments, pbar.update ): diff --git a/montreal_forced_aligner/acoustic_modeling/sat.py b/montreal_forced_aligner/acoustic_modeling/sat.py index 8706d8a4..a9e03b6b 100644 --- a/montreal_forced_aligner/acoustic_modeling/sat.py +++ b/montreal_forced_aligner/acoustic_modeling/sat.py @@ -13,7 +13,7 @@ from queue import Empty from typing import Dict, List -import tqdm +from tqdm.rich import tqdm from montreal_forced_aligner.acoustic_modeling.triphone import TriphoneTrainer from montreal_forced_aligner.config import GLOBAL_CONFIG @@ -341,7 +341,7 @@ def create_align_model(self) -> None: begin = time.time() arguments = self.acc_stats_two_feats_arguments() - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() diff --git a/montreal_forced_aligner/acoustic_modeling/trainer.py b/montreal_forced_aligner/acoustic_modeling/trainer.py index e00c6585..9d0940f5 100644 --- a/montreal_forced_aligner/acoustic_modeling/trainer.py +++ b/montreal_forced_aligner/acoustic_modeling/trainer.py @@ -15,8 +15,8 @@ from queue import Empty from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple -import tqdm from sqlalchemy.orm import Session, joinedload, subqueryload +from tqdm.rich import tqdm from montreal_forced_aligner.abc import KaldiFunction, ModelExporterMixin, TopLevelMfaWorker from montreal_forced_aligner.config import GLOBAL_CONFIG @@ -583,7 +583,7 @@ def compute_phone_pdf_counts(self) -> None: log_directory = self.working_log_directory os.makedirs(log_directory, exist_ok=True) arguments = self.transition_acc_arguments() - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() diff --git a/montreal_forced_aligner/acoustic_modeling/triphone.py b/montreal_forced_aligner/acoustic_modeling/triphone.py index 420798c3..eff89373 100644 --- a/montreal_forced_aligner/acoustic_modeling/triphone.py +++ b/montreal_forced_aligner/acoustic_modeling/triphone.py @@ -11,7 +11,7 @@ from queue import Empty from typing import TYPE_CHECKING, Dict, List -import tqdm +from tqdm.rich import tqdm from montreal_forced_aligner.acoustic_modeling.base import AcousticModelTrainingMixin from montreal_forced_aligner.config import GLOBAL_CONFIG @@ -294,7 +294,7 @@ def convert_alignments(self) -> None: """ logger.info("Converting alignments...") arguments = self.convert_alignments_arguments() - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() diff --git a/montreal_forced_aligner/alignment/adapting.py b/montreal_forced_aligner/alignment/adapting.py index 312b8389..2e6bc094 100644 --- a/montreal_forced_aligner/alignment/adapting.py +++ b/montreal_forced_aligner/alignment/adapting.py @@ -11,7 +11,7 @@ from queue import Empty from typing import TYPE_CHECKING, List -import tqdm +from tqdm.rich import tqdm from montreal_forced_aligner.abc import AdapterMixin from montreal_forced_aligner.alignment.multiprocessing import AccStatsArguments, AccStatsFunction @@ -124,7 +124,7 @@ def acc_stats(self, alignment: bool = False) -> None: initial_mdl_path = self.working_directory.joinpath("unadapted.mdl") final_mdl_path = self.working_directory.joinpath("final.mdl") logger.info("Accumulating statistics...") - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() diff --git a/montreal_forced_aligner/alignment/base.py b/montreal_forced_aligner/alignment/base.py index 683538c2..6a795799 100644 --- a/montreal_forced_aligner/alignment/base.py +++ b/montreal_forced_aligner/alignment/base.py @@ -16,8 +16,8 @@ from typing import Dict, List, Optional import sqlalchemy -import tqdm from sqlalchemy.orm import joinedload, subqueryload +from tqdm.rich import tqdm from montreal_forced_aligner.abc import FileExporterMixin from montreal_forced_aligner.alignment.mixins import AlignMixin @@ -327,7 +327,7 @@ def compute_pronunciation_probabilities(self): } logger.info("Generating pronunciations...") arguments = self.generate_pronunciations_arguments() - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() @@ -696,7 +696,7 @@ def collect_alignments(self) -> None: if max_word_interval_id is None: max_word_interval_id = 0 - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: logger.info(f"Collecting phone and word alignments from {workflow.name} lattices...") arguments = self.alignment_extraction_arguments() @@ -867,7 +867,7 @@ def fine_tune_alignments(self) -> None: """ logger.info("Fine tuning alignments...") begin = time.time() - with self.session() as session, tqdm.tqdm( + with self.session() as session, tqdm( total=self.num_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar: update_mappings = [] @@ -1027,7 +1027,7 @@ def export_textgrids( begin = time.time() error_dict = {} - with tqdm.tqdm(total=self.num_files, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_files, disable=GLOBAL_CONFIG.quiet) as pbar: with self.session() as session: files = ( session.query( diff --git a/montreal_forced_aligner/alignment/mixins.py b/montreal_forced_aligner/alignment/mixins.py index 44a265a5..1a4063db 100644 --- a/montreal_forced_aligner/alignment/mixins.py +++ b/montreal_forced_aligner/alignment/mixins.py @@ -12,7 +12,7 @@ from queue import Empty from typing import TYPE_CHECKING, Dict, List -import tqdm +from tqdm.rich import tqdm from montreal_forced_aligner.alignment.multiprocessing import ( AlignArguments, @@ -298,7 +298,7 @@ def compile_train_graphs(self) -> None: logger.info("Compiling training graphs...") error_sum = 0 arguments = self.compile_train_graphs_arguments() - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() @@ -351,7 +351,7 @@ def get_phone_confidences(self): begin = time.time() with self.session() as session: - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: arguments = self.phone_confidence_arguments() interval_update_mappings = [] if GLOBAL_CONFIG.use_mp: @@ -416,7 +416,7 @@ def align_utterances(self, training=False) -> None: """ begin = time.time() logger.info("Generating alignments...") - with tqdm.tqdm( + with tqdm( total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar, self.session() as session: if not training: diff --git a/montreal_forced_aligner/command_line/adapt.py b/montreal_forced_aligner/command_line/adapt.py index 8e51f551..2fd51197 100644 --- a/montreal_forced_aligner/command_line/adapt.py +++ b/montreal_forced_aligner/command_line/adapt.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.alignment import AdaptingAligner from montreal_forced_aligner.command_line.utils import ( diff --git a/montreal_forced_aligner/command_line/align.py b/montreal_forced_aligner/command_line/align.py index 5edc1551..c1fda2d1 100644 --- a/montreal_forced_aligner/command_line/align.py +++ b/montreal_forced_aligner/command_line/align.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click import yaml from montreal_forced_aligner.alignment import PretrainedAligner diff --git a/montreal_forced_aligner/command_line/anchor.py b/montreal_forced_aligner/command_line/anchor.py index 5bb25ae7..7d7a7403 100644 --- a/montreal_forced_aligner/command_line/anchor.py +++ b/montreal_forced_aligner/command_line/anchor.py @@ -1,12 +1,15 @@ """Command line functions for launching anchor annotation""" from __future__ import annotations +import logging import sys -import click +import rich_click as click __all__ = ["anchor_cli"] +logger = logging.getLogger("mfa") + @click.command(name="anchor", short_help="Launch Anchor") @click.help_option("-h", "--help") @@ -17,8 +20,7 @@ def anchor_cli(*args, **kwargs) -> None: # pragma: no cover try: from anchor.command_line import main except ImportError: - raise - print( + logger.error( "Anchor annotator utility is not installed, please install it via pip install anchor-annotator." ) sys.exit(1) diff --git a/montreal_forced_aligner/command_line/configure.py b/montreal_forced_aligner/command_line/configure.py index d5e398e2..5cbd3690 100644 --- a/montreal_forced_aligner/command_line/configure.py +++ b/montreal_forced_aligner/command_line/configure.py @@ -1,6 +1,6 @@ import os -import click +import rich_click as click from montreal_forced_aligner.config import GLOBAL_CONFIG, MFA_PROFILE_VARIABLE diff --git a/montreal_forced_aligner/command_line/create_segments.py b/montreal_forced_aligner/command_line/create_segments.py index a47198ad..f32b0889 100644 --- a/montreal_forced_aligner/command_line/create_segments.py +++ b/montreal_forced_aligner/command_line/create_segments.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.command_line.utils import ( check_databases, diff --git a/montreal_forced_aligner/command_line/diarize_speakers.py b/montreal_forced_aligner/command_line/diarize_speakers.py index 676b1e44..bd632210 100644 --- a/montreal_forced_aligner/command_line/diarize_speakers.py +++ b/montreal_forced_aligner/command_line/diarize_speakers.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.command_line.utils import ( check_databases, diff --git a/montreal_forced_aligner/command_line/g2p.py b/montreal_forced_aligner/command_line/g2p.py index 985385bb..9162e4c5 100644 --- a/montreal_forced_aligner/command_line/g2p.py +++ b/montreal_forced_aligner/command_line/g2p.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.command_line.utils import ( check_databases, diff --git a/montreal_forced_aligner/command_line/history.py b/montreal_forced_aligner/command_line/history.py index 0141474c..9b0326b2 100644 --- a/montreal_forced_aligner/command_line/history.py +++ b/montreal_forced_aligner/command_line/history.py @@ -1,11 +1,14 @@ +import logging import time -import click +import rich_click as click from montreal_forced_aligner.config import GLOBAL_CONFIG, load_command_history __all__ = ["history_cli"] +logger = logging.getLogger("mfa") + @click.command( "history", @@ -26,14 +29,14 @@ def history_cli(depth: int, verbose: bool) -> None: """ history = load_command_history()[-depth:] if verbose: - print("command\tDate\tExecution time\tVersion\tExit code\tException") + logger.info("command\tDate\tExecution time\tVersion\tExit code\tException") for h in history: execution_time = time.strftime("%H:%M:%S", time.gmtime(h["execution_time"])) d = h["date"].isoformat() - print( + logger.info( f"{h['command']}\t{d}\t{execution_time}\t{h.get('version', 'unknown')}\t{h['exit_code']}\t{h['exception']}" ) pass else: for h in history: - print(h["command"]) + logger.info(h["command"]) diff --git a/montreal_forced_aligner/command_line/mfa.py b/montreal_forced_aligner/command_line/mfa.py index 0e27f66b..e0af89a9 100644 --- a/montreal_forced_aligner/command_line/mfa.py +++ b/montreal_forced_aligner/command_line/mfa.py @@ -8,7 +8,7 @@ import warnings from datetime import datetime -import click +import rich_click as click from montreal_forced_aligner.command_line.adapt import adapt_model_cli from montreal_forced_aligner.command_line.align import align_corpus_cli @@ -107,17 +107,14 @@ def mfa_cli(ctx: click.Context) -> None: GLOBAL_CONFIG.load() from montreal_forced_aligner.helper import configure_logger - if not GLOBAL_CONFIG.current_profile.debug: - warnings.simplefilter("ignore") + warnings.simplefilter("ignore") configure_logger("mfa") check_third_party() if ctx.invoked_subcommand != "anchor": hooks = ExitHooks() hooks.hook() atexit.register(hooks.history_save_handler) - from colorama import init - init() mp.freeze_support() diff --git a/montreal_forced_aligner/command_line/model.py b/montreal_forced_aligner/command_line/model.py index df6f5ac6..246fbf11 100644 --- a/montreal_forced_aligner/command_line/model.py +++ b/montreal_forced_aligner/command_line/model.py @@ -7,7 +7,7 @@ import typing from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.command_line.utils import ( check_databases, @@ -42,7 +42,7 @@ @click.help_option("-h", "--help") def model_cli() -> None: """ - Inspect, download, and save pretrained MFA models + Inspect, download, and save pretrained MFA models and dictionaries """ pass @@ -100,8 +100,8 @@ def inspect_model_cli(model_type: str, model: str) -> None: from montreal_forced_aligner.config import GLOBAL_CONFIG, get_temporary_directory GLOBAL_CONFIG.current_profile.clean = True - GLOBAL_CONFIG.current_profile.temporary_directory = os.path.join( - get_temporary_directory(), "model_inspect" + GLOBAL_CONFIG.current_profile.temporary_directory = get_temporary_directory().joinpath( + "model_inspect" ) shutil.rmtree(GLOBAL_CONFIG.current_profile.temporary_directory, ignore_errors=True) if model_type and model_type not in MODEL_TYPES: @@ -130,8 +130,8 @@ def inspect_model_cli(model_type: str, model: str) -> None: if path is None: raise PretrainedModelNotFoundError(model) model = path - working_dir = os.path.join(get_temporary_directory(), "models", "inspect") - ext = os.path.splitext(model)[1] + working_dir = get_temporary_directory().joinpath("models", "inspect") + ext = model.suffix if model_type: if model_type == MODEL_TYPES["dictionary"]: m = MODEL_TYPES[model_type](model, working_dir, phone_set_type=PhoneSetType.AUTO) @@ -218,13 +218,13 @@ def save_model_cli(path: Path, model_type: str, name: str, overwrite: bool) -> N Type of model """ logger = logging.getLogger("mfa") - model_name = os.path.splitext(os.path.basename(path))[0] + model_name = path.stem model_class = MODEL_TYPES[model_type] if name: out_path = model_class.get_pretrained_path(name, enforce_existence=False) else: out_path = model_class.get_pretrained_path(model_name, enforce_existence=False) - if not overwrite and os.path.exists(out_path): + if not overwrite and out_path.exists(): raise ModelSaveError(out_path) shutil.copyfile(path, out_path) logger.info( diff --git a/montreal_forced_aligner/command_line/tokenize.py b/montreal_forced_aligner/command_line/tokenize.py index d46b0c1d..57c31b75 100644 --- a/montreal_forced_aligner/command_line/tokenize.py +++ b/montreal_forced_aligner/command_line/tokenize.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.command_line.utils import ( check_databases, diff --git a/montreal_forced_aligner/command_line/train_acoustic_model.py b/montreal_forced_aligner/command_line/train_acoustic_model.py index 6a8b0832..e6dcc1a8 100644 --- a/montreal_forced_aligner/command_line/train_acoustic_model.py +++ b/montreal_forced_aligner/command_line/train_acoustic_model.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.acoustic_modeling import TrainableAligner from montreal_forced_aligner.command_line.utils import ( diff --git a/montreal_forced_aligner/command_line/train_dictionary.py b/montreal_forced_aligner/command_line/train_dictionary.py index a1c0b0cd..a2f23fd2 100644 --- a/montreal_forced_aligner/command_line/train_dictionary.py +++ b/montreal_forced_aligner/command_line/train_dictionary.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.alignment.pretrained import DictionaryTrainer from montreal_forced_aligner.command_line.utils import ( diff --git a/montreal_forced_aligner/command_line/train_g2p.py b/montreal_forced_aligner/command_line/train_g2p.py index 1bffec6c..7fd83fba 100644 --- a/montreal_forced_aligner/command_line/train_g2p.py +++ b/montreal_forced_aligner/command_line/train_g2p.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.command_line.utils import ( check_databases, diff --git a/montreal_forced_aligner/command_line/train_ivector_extractor.py b/montreal_forced_aligner/command_line/train_ivector_extractor.py index 2980abb7..f94f73f7 100644 --- a/montreal_forced_aligner/command_line/train_ivector_extractor.py +++ b/montreal_forced_aligner/command_line/train_ivector_extractor.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.command_line.utils import ( check_databases, diff --git a/montreal_forced_aligner/command_line/train_lm.py b/montreal_forced_aligner/command_line/train_lm.py index 9959440b..ca61b112 100644 --- a/montreal_forced_aligner/command_line/train_lm.py +++ b/montreal_forced_aligner/command_line/train_lm.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.command_line.utils import ( check_databases, diff --git a/montreal_forced_aligner/command_line/train_tokenizer.py b/montreal_forced_aligner/command_line/train_tokenizer.py index 2cf241ed..76c6acfc 100644 --- a/montreal_forced_aligner/command_line/train_tokenizer.py +++ b/montreal_forced_aligner/command_line/train_tokenizer.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.command_line.utils import ( check_databases, diff --git a/montreal_forced_aligner/command_line/transcribe.py b/montreal_forced_aligner/command_line/transcribe.py index 0742e3d4..e6fb0a8b 100644 --- a/montreal_forced_aligner/command_line/transcribe.py +++ b/montreal_forced_aligner/command_line/transcribe.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.command_line.utils import ( check_databases, diff --git a/montreal_forced_aligner/command_line/utils.py b/montreal_forced_aligner/command_line/utils.py index 4100a6c2..17eaac26 100644 --- a/montreal_forced_aligner/command_line/utils.py +++ b/montreal_forced_aligner/command_line/utils.py @@ -8,7 +8,7 @@ import typing from pathlib import Path -import click +import rich_click as click import sqlalchemy import yaml diff --git a/montreal_forced_aligner/command_line/validate.py b/montreal_forced_aligner/command_line/validate.py index 9adf7e9c..3361c5e4 100644 --- a/montreal_forced_aligner/command_line/validate.py +++ b/montreal_forced_aligner/command_line/validate.py @@ -4,7 +4,7 @@ import os from pathlib import Path -import click +import rich_click as click from montreal_forced_aligner.command_line.utils import ( check_databases, diff --git a/montreal_forced_aligner/config.py b/montreal_forced_aligner/config.py index e6cf1ee2..c12e194d 100644 --- a/montreal_forced_aligner/config.py +++ b/montreal_forced_aligner/config.py @@ -11,9 +11,9 @@ import typing from typing import Any, Dict, List, Union -import click import dataclassy import joblib +import rich_click as click import yaml from dataclassy import dataclass @@ -52,8 +52,8 @@ def get_temporary_directory() -> pathlib.Path: :class:`~montreal_forced_aligner.exceptions.RootDirectoryError` """ TEMP_DIR = pathlib.Path( - os.environ.get(MFA_ROOT_ENVIRONMENT_VARIABLE, os.path.expanduser("~/Documents/MFA")) - ) + os.environ.get(MFA_ROOT_ENVIRONMENT_VARIABLE, "~/Documents/MFA") + ).expanduser() try: TEMP_DIR.mkdir(parents=True, exist_ok=True) except OSError: diff --git a/montreal_forced_aligner/corpus/acoustic_corpus.py b/montreal_forced_aligner/corpus/acoustic_corpus.py index 72bbf18e..263fc468 100644 --- a/montreal_forced_aligner/corpus/acoustic_corpus.py +++ b/montreal_forced_aligner/corpus/acoustic_corpus.py @@ -14,7 +14,7 @@ from typing import List, Optional import sqlalchemy -import tqdm +from tqdm.rich import tqdm from montreal_forced_aligner.abc import MfaWorker from montreal_forced_aligner.config import GLOBAL_CONFIG @@ -228,7 +228,7 @@ def load_reference_alignments(self, reference_directory: Path) -> None: indices = [] jobs = [] reference_intervals = [] - with tqdm.tqdm( + with tqdm( total=self.num_files, disable=GLOBAL_CONFIG.quiet ) as pbar, self.session() as session: phone_mapping = {} @@ -385,7 +385,7 @@ def generate_final_features(self) -> None: log_directory = self.split_directory.joinpath("log") os.makedirs(log_directory, exist_ok=True) arguments = self.final_feature_arguments() - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: for _ in run_kaldi_function(FinalFeatureFunction, arguments, pbar.update): pass with self.session() as session: @@ -488,7 +488,7 @@ def create_corpus_split(self) -> None: else: logger.info("Creating corpus split for feature generation...") os.makedirs(self.split_directory.joinpath("log"), exist_ok=True) - with self.session() as session, tqdm.tqdm( + with self.session() as session, tqdm( total=self.num_utterances + self.num_files, disable=GLOBAL_CONFIG.quiet ) as pbar: jobs = session.query(Job) @@ -636,7 +636,7 @@ def compute_speaker_pitch_ranges(self): os.makedirs(log_directory, exist_ok=True) arguments = self.pitch_range_arguments() update_mapping = [] - with tqdm.tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: for speaker_id, min_f0, max_f0 in run_kaldi_function( PitchRangeFunction, arguments, pbar.update ): @@ -665,7 +665,7 @@ def mfcc(self) -> None: log_directory = self.split_directory.joinpath("log") os.makedirs(log_directory, exist_ok=True) arguments = self.mfcc_arguments() - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: for _ in run_kaldi_function(MfccFunction, arguments, pbar.update): pass logger.debug(f"Generating MFCCs took {time.time() - begin:.3f} seconds") @@ -736,7 +736,7 @@ def calc_fmllr(self, iteration: Optional[int] = None) -> None: logger.info("Calculating fMLLR for speaker adaptation...") arguments = self.calc_fmllr_arguments(iteration=iteration) - with tqdm.tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() @@ -797,7 +797,7 @@ def compute_vad(self) -> None: logger.info("Computing VAD...") arguments = self.compute_vad_arguments() - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() @@ -967,9 +967,7 @@ def _load_corpus_from_source_mp(self) -> None: p.start() last_poll = time.time() - 30 try: - with self.session() as session, tqdm.tqdm( - total=100, disable=GLOBAL_CONFIG.quiet - ) as pbar: + with self.session() as session, tqdm(total=100, disable=GLOBAL_CONFIG.quiet) as pbar: import_data = DatabaseImportData() while True: try: diff --git a/montreal_forced_aligner/corpus/base.py b/montreal_forced_aligner/corpus/base.py index e4281d94..e778b2bb 100644 --- a/montreal_forced_aligner/corpus/base.py +++ b/montreal_forced_aligner/corpus/base.py @@ -9,8 +9,8 @@ from pathlib import Path import sqlalchemy.engine -import tqdm from sqlalchemy.orm import Session, joinedload, selectinload, subqueryload +from tqdm.rich import tqdm from montreal_forced_aligner.abc import DatabaseMixin, MfaWorker from montreal_forced_aligner.config import GLOBAL_CONFIG @@ -302,7 +302,7 @@ def _write_spk2utt(self) -> None: def create_corpus_split(self) -> None: """Create split directory and output information from Jobs""" os.makedirs(self.split_directory.joinpath("log"), exist_ok=True) - with self.session() as session, tqdm.tqdm( + with self.session() as session, tqdm( total=self.num_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar: jobs = session.query(Job) @@ -662,7 +662,7 @@ def normalize_text(self) -> None: update_mapping = [] word_key = self.get_next_primary_key(Word) pronunciation_key = self.get_next_primary_key(Pronunciation) - with tqdm.tqdm( + with tqdm( total=self.num_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar, self.session() as session: dictionaries: typing.Dict[int, Dictionary] = { @@ -1169,7 +1169,7 @@ def create_subset(self, subset: int) -> None: session.commit() logger.debug(f"Setting subset flags took {time.time()-begin} seconds") - with self.session() as session, tqdm.tqdm( + with self.session() as session, tqdm( total=subset, disable=GLOBAL_CONFIG.quiet ) as pbar: jobs = ( diff --git a/montreal_forced_aligner/corpus/ivector_corpus.py b/montreal_forced_aligner/corpus/ivector_corpus.py index 125bed20..fe68be88 100644 --- a/montreal_forced_aligner/corpus/ivector_corpus.py +++ b/montreal_forced_aligner/corpus/ivector_corpus.py @@ -10,7 +10,7 @@ import numpy as np import sqlalchemy -import tqdm +from tqdm.rich import tqdm from montreal_forced_aligner.config import GLOBAL_CONFIG, IVECTOR_DIMENSION from montreal_forced_aligner.corpus.acoustic_corpus import AcousticCorpusMixin @@ -187,7 +187,7 @@ def compute_plda(self) -> None: if self.stopped.stop_check(): logger.debug("PLDA computation stopped early.") return - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar, mfa_open( + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar, mfa_open( log_path, "w" ) as log_file: @@ -262,7 +262,7 @@ def extract_ivectors(self) -> None: return logger.info("Extracting ivectors...") arguments = self.extract_ivectors_arguments() - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: for _ in run_kaldi_function(ExtractIvectorsFunction, arguments, pbar.update): pass self.collect_utterance_ivectors() @@ -314,7 +314,7 @@ def collect_utterance_ivectors(self) -> None: for line in f: scp_line = line.strip().split(maxsplit=1) ivector_arks[int(scp_line[0].split("-")[-1])] = scp_line[-1] - with self.session() as session, tqdm.tqdm( + with self.session() as session, tqdm( total=self.num_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar: update_mapping = {} @@ -382,7 +382,7 @@ def collect_speaker_ivectors(self) -> None: num_utts_path = self.working_directory.joinpath("current_num_utts.ark") if not os.path.exists(speaker_ivector_ark_path): self.compute_speaker_ivectors() - with self.session() as session, tqdm.tqdm( + with self.session() as session, tqdm( total=self.num_speakers, disable=GLOBAL_CONFIG.quiet ) as pbar: utterance_counts = {} diff --git a/montreal_forced_aligner/corpus/text_corpus.py b/montreal_forced_aligner/corpus/text_corpus.py index e51c91f1..8fc8599b 100644 --- a/montreal_forced_aligner/corpus/text_corpus.py +++ b/montreal_forced_aligner/corpus/text_corpus.py @@ -9,7 +9,7 @@ from pathlib import Path from queue import Empty -import tqdm +from tqdm.rich import tqdm from montreal_forced_aligner.abc import MfaWorker, TemporaryDirectoryMixin from montreal_forced_aligner.config import GLOBAL_CONFIG @@ -65,9 +65,7 @@ def _load_corpus_from_source_mp(self) -> None: import_data = DatabaseImportData() try: file_count = 0 - with tqdm.tqdm( - total=1, disable=GLOBAL_CONFIG.quiet - ) as pbar, self.session() as session: + with tqdm(total=1, disable=GLOBAL_CONFIG.quiet) as pbar, self.session() as session: for root, _, files in os.walk(self.corpus_directory, followlinks=True): exts = find_exts(files) relative_path = ( diff --git a/montreal_forced_aligner/diarization/speaker_diarizer.py b/montreal_forced_aligner/diarization/speaker_diarizer.py index 4003a9da..8bd5ab2a 100644 --- a/montreal_forced_aligner/diarization/speaker_diarizer.py +++ b/montreal_forced_aligner/diarization/speaker_diarizer.py @@ -20,10 +20,10 @@ import numpy as np import sqlalchemy -import tqdm import yaml from sklearn import decomposition, metrics from sqlalchemy.orm import joinedload, selectinload +from tqdm.rich import tqdm from montreal_forced_aligner.abc import FileExporterMixin, TopLevelMfaWorker from montreal_forced_aligner.alignment.multiprocessing import construct_output_path @@ -323,7 +323,7 @@ def classify_speakers(self): self.setup() logger.info("Classifying utterances...") - with self.session() as session, tqdm.tqdm( + with self.session() as session, tqdm( total=self.num_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar, mfa_open( self.working_directory.joinpath("speaker_classification_results.csv"), "w" @@ -633,7 +633,7 @@ def visualize_clusters(self, ivectors, cluster_labels=None): def export_xvectors(self): logger.info("Exporting SpeechBrain embeddings...") os.makedirs(self.split_directory, exist_ok=True) - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: arguments = [ ExportIvectorsArguments( j.id, @@ -703,7 +703,7 @@ def initialize_mfa_clustering(self): logger.info("Generating initial speaker labels...") utt2spk = {k: v for k, v in session.query(Utterance.id, Utterance.speaker_id)} - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: for utt_id, classified_speaker, score in run_kaldi_function( func, arguments, pbar.update ): @@ -753,7 +753,7 @@ def initialize_mfa_clustering(self): def export_speaker_ivectors(self): logger.info("Exporting current speaker ivectors...") - with self.session() as session, tqdm.tqdm( + with self.session() as session, tqdm( total=self.num_speakers, disable=GLOBAL_CONFIG.quiet ) as pbar, mfa_open(self.num_utts_path, "w") as f: if self.use_xvector: @@ -806,7 +806,7 @@ def classify_iteration(self, iteration=None) -> None: self.max_iterations, )[iteration] logger.debug(f"Score threshold: {score_threshold}") - with self.session() as session, tqdm.tqdm( + with self.session() as session, tqdm( total=self.num_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar: @@ -876,9 +876,7 @@ def breakup_large_clusters(self): logger.info("Breaking up large speakers...") logger.debug(f"Unknown speaker is {unknown_speaker_id}") next_speaker_id = self.get_next_primary_key(Speaker) - with tqdm.tqdm( - total=len(above_threshold_speakers), disable=GLOBAL_CONFIG.quiet - ) as pbar: + with tqdm(total=len(above_threshold_speakers), disable=GLOBAL_CONFIG.quiet) as pbar: utterance_mapping = [] new_speakers = {} for s_id in above_threshold_speakers: @@ -1262,7 +1260,7 @@ def calculate_eer(self) -> typing.Tuple[float, float]: limit_per_speaker = 5 limit_within_speaker = 30 begin = time.time() - with tqdm.tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: arguments = [ ComputeEerArguments( j.id, @@ -1309,7 +1307,7 @@ def load_embeddings(self) -> None: logger.info("Embeddings already loaded.") return logger.info("Loading SpeechBrain embeddings...") - with tqdm.tqdm( + with tqdm( total=self.num_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar, self.session() as session: begin = time.time() @@ -1361,7 +1359,7 @@ def load_embeddings(self) -> None: def refresh_plda_vectors(self): logger.info("Refreshing PLDA vectors...") self.plda = PldaModel.load(self.plda_path) - with self.session() as session, tqdm.tqdm( + with self.session() as session, tqdm( total=self.num_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar: if self.use_xvector: @@ -1391,7 +1389,7 @@ def refresh_plda_vectors(self): def refresh_speaker_vectors(self) -> None: """Refresh speaker vectors following clustering or classification""" logger.info("Refreshing speaker vectors...") - with self.session() as session, tqdm.tqdm( + with self.session() as session, tqdm( total=self.num_speakers, disable=GLOBAL_CONFIG.quiet ) as pbar: if self.use_xvector: @@ -1431,7 +1429,7 @@ def compute_speaker_embeddings(self) -> None: if not self.has_xvectors(): self.load_embeddings() logger.info("Computing SpeechBrain speaker embeddings...") - with tqdm.tqdm( + with tqdm( total=self.num_speakers, disable=GLOBAL_CONFIG.quiet ) as pbar, self.session() as session: update_mapping = [] @@ -1500,7 +1498,7 @@ def export_files(self, output_directory: str) -> None: joinedload(File.sound_file, innerjoin=True).load_only(SoundFile.duration), joinedload(File.text_file, innerjoin=True).load_only(TextFile.file_type), ) - with tqdm.tqdm(total=self.num_files, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_files, disable=GLOBAL_CONFIG.quiet) as pbar: for file in files: utterance_count = len(file.utterances) diff --git a/montreal_forced_aligner/dictionary/multispeaker.py b/montreal_forced_aligner/dictionary/multispeaker.py index aa80ad4c..d75bd634 100644 --- a/montreal_forced_aligner/dictionary/multispeaker.py +++ b/montreal_forced_aligner/dictionary/multispeaker.py @@ -16,9 +16,9 @@ import pynini import pywrapfst import sqlalchemy.orm.session -import tqdm import yaml from sqlalchemy.orm import selectinload +from tqdm.rich import tqdm from montreal_forced_aligner.config import GLOBAL_CONFIG from montreal_forced_aligner.data import PhoneType, WordType @@ -620,7 +620,7 @@ def apply_phonological_rules(self) -> None: with self.session() as session: num_words = session.query(Word).count() logger.info("Applying phonological rules...") - with tqdm.tqdm(total=num_words, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=num_words, disable=GLOBAL_CONFIG.quiet) as pbar: new_pron_objs = [] rule_application_objs = [] dialect_ids = {d.name: d.id for d in session.query(Dialect).all()} diff --git a/montreal_forced_aligner/exceptions.py b/montreal_forced_aligner/exceptions.py index e2affd2b..ebb7748c 100644 --- a/montreal_forced_aligner/exceptions.py +++ b/montreal_forced_aligner/exceptions.py @@ -15,7 +15,7 @@ import requests.structures -from montreal_forced_aligner.helper import TerminalPrinter, comma_join +from montreal_forced_aligner.helper import comma_join if TYPE_CHECKING: from montreal_forced_aligner.dictionary.mixins import DictionaryMixin @@ -65,20 +65,17 @@ class MFAError(Exception): """ def __init__(self, base_error_message: str, *args, **kwargs): - self.printer = TerminalPrinter() self.message_lines: List[str] = [base_error_message] @property def message(self) -> str: """Formatted exception message""" - return "\n".join(self.printer.format_info_lines(self.message_lines)) + return "\n".join(self.message_lines) def __str__(self) -> str: """Output the error""" - message = self.printer.error_text(type(self).__name__) + ":" - self.printer.indent_level += 1 + message = type(self).__name__ + ":" message += "\n\n" + self.message - self.printer.indent_level -= 1 return message @@ -95,12 +92,12 @@ class PlatformError(MFAError): def __init__(self, functionality_name): super().__init__("") self.message_lines = [ - f"Functionality for {self.printer.emphasized_text(functionality_name)} is not available on {self.printer.error_text(sys.platform)}." + f"Functionality for {functionality_name} is not available on {sys.platform}." ] if sys.platform == "win32": self.message_lines.append("") self.message_lines.append( - f" If you'd like to use {self.printer.emphasized_text(functionality_name)} on Windows, please follow the MFA installation " + f" If you'd like to use {functionality_name} on Windows, please follow the MFA installation " f"instructions for the Windows Subsystem for Linux (WSL)." ) @@ -129,39 +126,37 @@ def __init__( super().__init__("") if error_text: self.message_lines = [ - f"There was an error when invoking '{self.printer.error_text(binary_name)}':", + f"There was an error when invoking '{binary_name}':", error_text, "This likely indicates that MFA's dependencies were not correctly installed, or there is an issue with your Conda environment.", "If you are in the correct environment, please try re-creating the environment from scratch as a first step, i.e.:", - self.printer.pass_text( - "conda create -n aligner -c conda-forge montreal-forced-aligner" - ), + "conda create -n aligner -c conda-forge montreal-forced-aligner", ] else: - self.message_lines = [f"Could not find '{self.printer.error_text(binary_name)}'."] + self.message_lines = [f"Could not find '{binary_name}'."] self.message_lines.append( "Please ensure that you have installed MFA's conda dependencies and are in the correct environment." ) if open_fst: self.message_lines.append( - f"Please ensure that you are in an environment that has the {self.printer.emphasized_text('openfst')} conda package installed, " - f"or that the {self.printer.emphasized_text('openfst')} binaries are on your path if you compiled them yourself." + f"Please ensure that you are in an environment that has the {'openfst'} conda package installed, " + f"or that the {'openfst'} binaries are on your path if you compiled them yourself." ) elif open_blas: self.message_lines.append( - f"Try installing {self.printer.emphasized_text('openblas')} via system package manager or verify it's on your system path?" + f"Try installing {'openblas'} via system package manager or verify it's on your system path?" ) elif libc: self.message_lines.append( - f"You likely have a different version of {self.printer.emphasized_text('glibc')} than the packages binaries use. " - f"Try compiling {self.printer.emphasized_text('Kaldi')} on your machine and collecting the binaries via the " - f"{self.printer.pass_text('mfa thirdparty kaldi')} command." + f"You likely have a different version of {'glibc'} than the packages binaries use. " + f"Try compiling {'Kaldi'} on your machine and collecting the binaries via the " + f"{'mfa thirdparty kaldi'} command." ) elif sox: self.message_lines = [] self.message_lines.append( - f"Your version of {self.printer.emphasized_text('sox')} does not support the file format in your corpus. " - f"Try installing another version of {self.printer.emphasized_text('sox')} with support for {self.printer.error_text(binary_name)}." + f"Your version of {'sox'} does not support the file format in your corpus. " + f"Try installing another version of {'sox'} with support for {binary_name}." ) @@ -199,9 +194,7 @@ class ModelLoadError(ModelError): def __init__(self, path: typing.Union[str, Path]): super().__init__("") - self.message_lines = [ - f"The archive {self.printer.error_text(path)} could not be parsed as an MFA model." - ] + self.message_lines = [f"The archive {path} could not be parsed as an MFA model."] class ModelSaveError(ModelError): @@ -217,7 +210,7 @@ class ModelSaveError(ModelError): def __init__(self, path: Path): super().__init__("") self.message_lines = [ - f"The archive {self.printer.error_text(path)} already exists.", + f"The archive {path} already exists.", "Please specify --overwrite if you would like to overwrite it.", ] @@ -247,9 +240,9 @@ def __init__( rate_limit = headers["x-ratelimit-limit"] rate_limit_reset = datetime.datetime.fromtimestamp(int(headers["x-ratelimit-reset"])) self.message_lines = [ - f"Current hourly rate limit ({self.printer.error_text(rate_limit)} per hour) has been exceeded for the GitHub API.", + f"Current hourly rate limit ({rate_limit} per hour) has been exceeded for the GitHub API.", "You can increase it by providing a personal authentication token to via --github_token.", - f"The rate limit will reset at {self.printer.pass_text(rate_limit_reset)}", + f"The rate limit will reset at {rate_limit_reset}", ] else: self.message_lines = [ @@ -280,7 +273,7 @@ class PhoneMismatchError(DictionaryError): def __init__(self, missing_phones: Collection[str]): super().__init__("There were extra phones that were not in the dictionary: ") - missing_phones = [f"{self.printer.error_text(x)}" for x in sorted(missing_phones)] + missing_phones = [f"{x}" for x in sorted(missing_phones)] self.message_lines.append(comma_join(missing_phones)) @@ -291,7 +284,7 @@ class NoDefaultSpeakerDictionaryError(DictionaryError): def __init__(self): super().__init__("") - self.message_lines = [f'No "{self.printer.error_text("default")}" dictionary was found.'] + self.message_lines = [f'No "{"default"}" dictionary was found.'] class DictionaryPathError(DictionaryError): @@ -307,7 +300,7 @@ class DictionaryPathError(DictionaryError): def __init__(self, input_path: Path): super().__init__("") self.message_lines = [ - f"The specified path for the dictionary ({self.printer.error_text(input_path)}) was not found." + f"The specified path for the dictionary ({input_path}) was not found." ] @@ -324,7 +317,7 @@ class DictionaryFileError(DictionaryError): def __init__(self, input_path: Path): super().__init__("") self.message_lines = [ - f"The specified path for the dictionary ({self.printer.error_text(input_path)}) is not a file." + f"The specified path for the dictionary ({input_path}) is not a file." ] @@ -351,7 +344,7 @@ class CorpusReadError(CorpusError): def __init__(self, file_name: str): super().__init__("") - self.message_lines = [f"There was an error reading {self.printer.error_text(file_name)}."] + self.message_lines = [f"There was an error reading {file_name}."] class TextParseError(CorpusReadError): @@ -367,8 +360,7 @@ class TextParseError(CorpusReadError): def __init__(self, file_name: str): super().__init__("") self.message_lines = [ - f"There was an error decoding {self.printer.error_text(file_name)}, " - f"maybe try resaving it as utf8?" + f"There was an error decoding {file_name}, " f"maybe try resaving it as utf8?" ] @@ -390,7 +382,7 @@ def __init__(self, file_name: str, error: str): self.error = error self.message_lines.extend( [ - f"Reading {self.printer.emphasized_text(file_name)} has the following error:", + f"Reading {file_name} has the following error:", "", "", self.error, @@ -424,7 +416,7 @@ def __init__(self, file_name: str, error: str): self.error = error self.message_lines.extend( [ - f"Reading {self.printer.emphasized_text(file_name)} has the following error:", + f"Reading {file_name} has the following error:", "", "", self.error, @@ -478,13 +470,13 @@ class AlignmentError(MFAError): def __init__(self, error_logs: List[str]): super().__init__("") self.message_lines = [ - f"There were {self.printer.error_text(len(error_logs))} job(s) with errors. " + f"There were {len(error_logs)} job(s) with errors. " f"For more information, please see:", "", "", ] for path in error_logs: - self.message_lines.append(self.printer.error_text(path)) + self.message_lines.append(path) class AlignmentExportError(AlignmentError): @@ -542,7 +534,7 @@ class PronunciationAcousticMismatchError(AlignerError): def __init__(self, missing_phones: Collection[str]): super().__init__("There were phones in the dictionary that do not have acoustic models: ") - missing_phones = [f"{self.printer.error_text(x)}" for x in sorted(missing_phones)] + missing_phones = [f"{x}" for x in sorted(missing_phones)] self.message_lines.append(comma_join(missing_phones)) @@ -563,7 +555,7 @@ def __init__(self, g2p_model: G2PModel, dictionary: DictionaryMixin): "There were graphemes in the corpus that are not covered by the G2P model:" ) missing_graphs = dictionary.graphemes - set(g2p_model.meta["graphemes"]) - missing_graphs = [f"{self.printer.error_text(x)}" for x in sorted(missing_graphs)] + missing_graphs = [f"{x}" for x in sorted(missing_graphs)] self.message_lines.append(comma_join(missing_graphs)) @@ -590,7 +582,7 @@ class FileArgumentNotFoundError(ArgumentError): def __init__(self, path: Path): super().__init__("") - self.message_lines = [f'Could not find "{self.printer.error_text(path)}".'] + self.message_lines = [f'Could not find "{path}".'] class PretrainedModelNotFoundError(ArgumentError): @@ -614,11 +606,9 @@ def __init__( extra = "" if model_type: extra += f" for {model_type}" - self.message_lines = [ - f'Could not find a model named "{self.printer.error_text(name)}"{extra}.' - ] + self.message_lines = [f'Could not find a model named "{name}"{extra}.'] if available: - available = [f"{self.printer.pass_text(x)}" for x in available] + available = [f"{x}" for x in available] self.message_lines.append(f"Available: {comma_join(available)}.") @@ -643,11 +633,9 @@ def __init__( extra = "" if model_type: extra += f" for {model_type}" - self.message_lines = [ - f'Could not find a model named "{self.printer.error_text(name)}"{extra}.' - ] + self.message_lines = [f'Could not find a model named "{name}"{extra}.'] if available: - available = [f"{self.printer.pass_text(x)}" for x in available] + available = [f"{x}" for x in available] self.message_lines.append(f"Available: {comma_join(available)}.") self.message_lines.append( "You can see all available models either on https://mfa-models.readthedocs.io/en/latest/ or https://github.com/MontrealCorpusTools/mfa-models/releases." @@ -672,8 +660,8 @@ class MultipleModelTypesFoundError(ArgumentError): def __init__(self, name: str, possible_model_types: List[str]): super().__init__("") - self.message_lines = [f'Found multiple model types for "{self.printer.error_text(name)}":'] - possible_model_types = [f"{self.printer.error_text(x)}" for x in possible_model_types] + self.message_lines = [f'Found multiple model types for "{name}":'] + possible_model_types = [f"{x}" for x in possible_model_types] self.message_lines.extend( [", ".join(possible_model_types), "Please specify a model type to inspect."] ) @@ -698,12 +686,10 @@ def __init__(self, name: str, model_type: str, extensions: List[str]): extra = "" if model_type: extra += f" for {model_type}" - self.message_lines = [ - f'The path "{self.printer.error_text(name)}" does not have the correct extensions{extra}.' - ] + self.message_lines = [f'The path "{name}" does not have the correct extensions{extra}.'] if extensions: - available = [f"{self.printer.pass_text(x)}" for x in extensions] + available = [f"{x}" for x in extensions] self.message_lines.append(f" Possible extensions: {comma_join(available)}.") @@ -721,11 +707,9 @@ class ModelTypeNotSupportedError(ArgumentError): def __init__(self, model_type, model_types): super().__init__("") - self.message_lines = [ - f'The model type "{self.printer.error_text(model_type)}" is not supported.' - ] + self.message_lines = [f'The model type "{model_type}" is not supported.'] if model_types: - model_types = [f"{self.printer.pass_text(x)}" for x in sorted(model_types)] + model_types = [f"{x}" for x in sorted(model_types)] self.message_lines.append(f" Possible model types: {comma_join(model_types)}.") @@ -745,8 +729,8 @@ class RootDirectoryError(ConfigError): def __init__(self, temporary_directory, variable): super().__init__("") self.message_lines = [ - f"Could not create a root MFA temporary directory (tried {self.printer.error_text(temporary_directory)}. ", - f"Please specify a write-able directory via the {self.printer.emphasized_text(variable)} environment variable.", + f"Could not create a root MFA temporary directory (tried {temporary_directory}. ", + f"Please specify a write-able directory via the {variable} environment variable.", ] @@ -775,10 +759,8 @@ def __init__(self, error_dict: Dict[str, Exception]): super().__init__("The following Pynini alignment jobs encountered errors:") self.message_lines.extend(["", ""]) for k, v in error_dict.items(): - self.message_lines.append(self.printer.indent_string + self.printer.error_text(k)) - self.message_lines.append( - self.printer.indent_string + self.printer.emphasized_text(str(v)) - ) + self.message_lines.append(k) + self.message_lines.append(str(v)) class PyniniGenerationError(G2PError): @@ -790,10 +772,8 @@ def __init__(self, error_dict: Dict[str, Exception]): super().__init__("The following words had errors in running G2P:") self.message_lines.extend(["", ""]) for k, v in error_dict.items(): - self.message_lines.append(self.printer.indent_string + self.printer.error_text(k)) - self.message_lines.append( - self.printer.indent_string + self.printer.emphasized_text(str(v)) - ) + self.message_lines.append(k) + self.message_lines.append(str(v)) class PhonetisaurusSymbolError(G2PError): @@ -845,7 +825,7 @@ class MultiprocessingError(MFAError): def __init__(self, job_name: int, error_text: str): super().__init__(f"Job {job_name} encountered an error:") - self.message_lines = [f"Job {self.printer.error_text(job_name)} encountered an error:"] + self.message_lines = [f"Job {job_name} encountered an error:"] self.job_name = job_name self.message_lines.extend( [self.highlight_line(x) for x in error_text.splitlines(keepends=False)] @@ -865,8 +845,8 @@ def highlight_line(self, line: str) -> str: str Highlighted line """ - emph_replacement = self.printer.emphasized_text(r"\1") - err_replacement = self.printer.error_text(r"\1") + emph_replacement = r"\1" + err_replacement = r"\1" line = re.sub(r"File \"(.*)\"", f'File "{emph_replacement}"', line) line = re.sub(r"line (\d+)", f"line {err_replacement}", line) return line @@ -908,9 +888,7 @@ def refresh_message(self) -> None: for line in f: self.message_lines.append(line.strip()) if self.log_file: - self.message_lines.append( - f" For more details, please check {self.printer.error_text(self.log_file)}" - ) + self.message_lines.append(f" For more details, please check {self.log_file}") def append_error_log(self, error_log: str) -> None: """ diff --git a/montreal_forced_aligner/g2p/generator.py b/montreal_forced_aligner/g2p/generator.py index 64d1efb9..f36e6329 100644 --- a/montreal_forced_aligner/g2p/generator.py +++ b/montreal_forced_aligner/g2p/generator.py @@ -13,10 +13,10 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Union import pynini -import tqdm from pynini import Fst, TokenType from pynini.lib import rewrite from pywrapfst import SymbolTable +from tqdm.rich import tqdm from montreal_forced_aligner.abc import DatabaseMixin, TopLevelMfaWorker from montreal_forced_aligner.config import GLOBAL_CONFIG @@ -412,7 +412,7 @@ def generate_pronunciations(self) -> Dict[str, List[str]]: to_return = {} skipped_words = 0 if num_words < 30 or GLOBAL_CONFIG.num_jobs == 1: - with tqdm.tqdm(total=num_words, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=num_words, disable=GLOBAL_CONFIG.quiet) as pbar: for word in self.words_to_g2p: w, m = clean_up_word(word, self.g2p_model.meta["graphemes"]) pbar.update(1) @@ -462,7 +462,7 @@ def generate_pronunciations(self) -> Dict[str, List[str]]: procs.append(p) p.start() num_words -= skipped_words - with tqdm.tqdm(total=num_words, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=num_words, disable=GLOBAL_CONFIG.quiet) as pbar: while True: try: word, result = return_queue.get(timeout=1) diff --git a/montreal_forced_aligner/g2p/phonetisaurus_trainer.py b/montreal_forced_aligner/g2p/phonetisaurus_trainer.py index 94c79293..a041bd87 100644 --- a/montreal_forced_aligner/g2p/phonetisaurus_trainer.py +++ b/montreal_forced_aligner/g2p/phonetisaurus_trainer.py @@ -14,9 +14,9 @@ import pynini import pywrapfst import sqlalchemy -import tqdm from pynini.lib import rewrite from sqlalchemy.orm import scoped_session, sessionmaker +from tqdm.rich import tqdm from montreal_forced_aligner.abc import MetaDict, TopLevelMfaWorker from montreal_forced_aligner.config import GLOBAL_CONFIG @@ -813,7 +813,7 @@ def initialize_alignments(self) -> None: symbols = {} job_symbols = {} symbol_id = 1 - with tqdm.tqdm( + with tqdm( total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet ) as pbar, self.session(autoflush=False, autocommit=False) as session: while True: @@ -921,9 +921,7 @@ def maximization(self, last_iteration=False) -> float: procs[-1].start() error_list = [] - with tqdm.tqdm( - total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet - ) as pbar: + with tqdm(total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet) as pbar: while True: try: result = return_queue.get(timeout=1) @@ -974,9 +972,7 @@ def expectation(self) -> None: procs[-1].start() mappings = {} zero = pynini.Weight.zero("log") - with tqdm.tqdm( - total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet - ) as pbar: + with tqdm(total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet) as pbar: while True: try: result = return_queue.get(timeout=1) @@ -1037,9 +1033,7 @@ def train_ngram_model(self) -> None: count_paths.append(args.far_path.with_suffix(".cnts")) procs[-1].start() - with tqdm.tqdm( - total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet - ) as pbar: + with tqdm(total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet) as pbar: while True: try: result = return_queue.get(timeout=1) @@ -1317,9 +1311,7 @@ def export_alignments(self) -> None: count_paths.append(args.far_path.with_suffix(".cnts")) procs[-1].start() - with tqdm.tqdm( - total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet - ) as pbar: + with tqdm(total=self.g2p_num_training_pronunciations, disable=GLOBAL_CONFIG.quiet) as pbar: while True: try: result = return_queue.get(timeout=1) diff --git a/montreal_forced_aligner/g2p/trainer.py b/montreal_forced_aligner/g2p/trainer.py index 6a4e9b8c..16e5765c 100644 --- a/montreal_forced_aligner/g2p/trainer.py +++ b/montreal_forced_aligner/g2p/trainer.py @@ -18,8 +18,8 @@ import pynini import pywrapfst -import tqdm from pynini import Fst +from tqdm.rich import tqdm from montreal_forced_aligner.abc import MetaDict, MfaWorker, TopLevelMfaWorker, TrainerMixin from montreal_forced_aligner.config import GLOBAL_CONFIG @@ -538,7 +538,7 @@ def _alignments(self) -> None: # Actually runs starts. logger.info("Calculating alignments...") begin = time.time() - with tqdm.tqdm( + with tqdm( total=num_commands * self.num_iterations, disable=GLOBAL_CONFIG.quiet ) as pbar: for start in starts: diff --git a/montreal_forced_aligner/helper.py b/montreal_forced_aligner/helper.py index 54f96c1c..c0d3fb5e 100644 --- a/montreal_forced_aligner/helper.py +++ b/montreal_forced_aligner/helper.py @@ -10,19 +10,18 @@ import json import logging import re -import shutil -import sys import typing from contextlib import contextmanager from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type -import ansiwrap import dataclassy import numpy import yaml from Bio import pairwise2 -from colorama import Fore, Style +from rich.console import Console +from rich.logging import RichHandler +from rich.theme import Theme if TYPE_CHECKING: from montreal_forced_aligner.abc import MetaDict @@ -30,7 +29,6 @@ __all__ = [ - "TerminalPrinter", "comma_join", "make_safe", "make_scp_safe", @@ -44,13 +42,24 @@ "overlap_scoring", "align_phones", "split_phone_position", - "CustomFormatter", "configure_logger", "mfa_open", "load_configuration", ] +console = Console( + theme=Theme( + { + "logging.level.debug": "cyan", + "logging.level.info": "green", + "logging.level.warning": "yellow", + "logging.level.error": "red", + } + ) +) + + @contextmanager def mfa_open(path, mode="r", encoding="utf8", newline=""): if "r" in mode: @@ -185,422 +194,19 @@ def configure_logger(identifier: str, log_file: Optional[Path] = None) -> None: file_handler.setFormatter(formatter) logger.addHandler(file_handler) elif not config.current_profile.quiet: - handler = logging.StreamHandler(sys.stdout) + handler = RichHandler( + rich_tracebacks=True, log_time_format="", console=console, show_path=False + ) if config.current_profile.verbose: handler.setLevel(logging.DEBUG) logging.getLogger("sqlalchemy.engine").setLevel(logging.DEBUG) logging.getLogger("sqlalchemy.pool").setLevel(logging.DEBUG) else: handler.setLevel(logging.INFO) - handler.setFormatter(CustomFormatter()) + handler.setFormatter(logging.Formatter("%(message)s")) logger.addHandler(handler) -class CustomFormatter(logging.Formatter): - """ - Custom log formatter class for MFA to highlight messages and incorporate terminal options from - the global configuration - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - from montreal_forced_aligner.config import GLOBAL_CONFIG - - use_colors = GLOBAL_CONFIG.terminal_colors - red = "" - green = "" - yellow = "" - blue = "" - reset = "" - if use_colors: - red = Fore.RED - green = Fore.GREEN - yellow = Fore.YELLOW - blue = Fore.CYAN - reset = Style.RESET_ALL - - self.FORMATS = { - logging.DEBUG: (f"{blue}DEBUG{reset} - ", "%(message)s"), - logging.INFO: (f"{green}INFO{reset} - ", "%(message)s"), - logging.WARNING: (f"{yellow}WARNING{reset} - ", "%(message)s"), - logging.ERROR: (f"{red}ERROR{reset} - ", "%(message)s"), - logging.CRITICAL: (f"{red}CRITICAL{reset} - ", "%(message)s"), - } - - def format(self, record: logging.LogRecord): - """ - Format a given log message - - Parameters - ---------- - record: logging.LogRecord - Log record to format - - Returns - ------- - str - Formatted log message - """ - log_fmt = self.FORMATS.get(record.levelno) - return ansiwrap.fill( - record.getMessage(), - initial_indent=log_fmt[0], - subsequent_indent=" " * len(log_fmt[0]), - width=shutil.get_terminal_size().columns, - ) - - -class TerminalPrinter: - """ - Helper class to output colorized text - - Parameters - ---------- - print_function: Callable, optional - Function to print information, defaults to :func:`print` - - Attributes - ---------- - colors: dict[str, str] - Mapping of color names to terminal codes in colorama (or empty strings - if the global terminal_colors flag is set to False) - """ - - def __init__(self, print_function: typing.Callable = None): - if print_function is not None: - self.print_function = print_function - else: - self.print_function = print - from montreal_forced_aligner.config import GLOBAL_CONFIG - - self.colors = {} - self.colors["bright"] = "" - self.colors["green"] = "" - self.colors["red"] = "" - self.colors["blue"] = "" - self.colors["cyan"] = "" - self.colors["yellow"] = "" - self.colors["reset"] = "" - self.colors["normal"] = "" - self.indent_level = 0 - self.indent_size = 2 - if GLOBAL_CONFIG.terminal_colors: - self.colors["bright"] = Style.BRIGHT - self.colors["green"] = Fore.GREEN - self.colors["red"] = Fore.RED - self.colors["blue"] = Fore.BLUE - self.colors["cyan"] = Fore.CYAN - self.colors["yellow"] = Fore.YELLOW - self.colors["reset"] = Style.RESET_ALL - self.colors["normal"] = Style.NORMAL - - def error_text(self, text: Any) -> str: - """ - Highlight text as an error - - Parameters - ---------- - text: Any - Text to highlight - - Returns - ------- - str - Highlighted text - """ - return self.colorize(str(text), "red") - - def emphasized_text(self, text: Any) -> str: - """ - Highlight text as emphasis - - Parameters - ---------- - text: Any - Text to highlight - - Returns - ------- - str - Highlighted text - """ - return self.colorize(str(text), "bright") - - def pass_text(self, text: Any) -> str: - """ - Highlight text as good - - Parameters - ---------- - text: Any - Text to highlight - - Returns - ------- - str - Highlighted text - """ - return self.colorize(str(text), "green") - - def warning_text(self, text: Any) -> str: - """ - Highlight text as a warning - - Parameters - ---------- - text: Any - Text to highlight - - Returns - ------- - str - Highlighted text - """ - return self.colorize(str(text), "yellow") - - @property - def indent_string(self) -> str: - """Indent string to use in formatting the output messages""" - return " " * self.indent_size * self.indent_level - - def print_header(self, header: str) -> None: - """ - Print a section header - - Parameters - ---------- - header: str - Section header string - """ - self.indent_level = 0 - self.print_function("") - underline = "*" * len(header) - self.print_function(self.colorize(underline, "bright")) - self.print_function(self.colorize(header, "bright")) - self.print_function(self.colorize(underline, "bright")) - self.print_function("") - self.indent_level += 1 - - def print_sub_header(self, header: str) -> None: - """ - Print a subsection header - - Parameters - ---------- - header: str - Subsection header string - """ - underline = "=" * len(header) - self.print_function(self.indent_string + self.colorize(header, "bright")) - self.print_function(self.indent_string + self.colorize(underline, "bright")) - self.print_function("") - self.indent_level += 1 - - def print_end_section(self) -> None: - """Mark the end of a section""" - self.indent_level -= 1 - self.print_function("") - - def format_info_lines(self, lines: Union[list[str], str]) -> List[str]: - """ - Format lines - - Parameters - ---------- - lines: Union[list[str], str - Lines to format - - Returns - ------- - str - Formatted string - """ - if isinstance(lines, str): - lines = [lines] - - for i, line in enumerate(lines): - lines[i] = ansiwrap.fill( - str(line), - initial_indent=self.indent_string, - subsequent_indent=" " * self.indent_size * (self.indent_level + 1), - width=shutil.get_terminal_size().columns, - break_on_hyphens=False, - break_long_words=False, - drop_whitespace=False, - ) - return lines - - def print_info_lines(self, lines: Union[list[str], str]) -> None: - """ - Print formatted information lines - - Parameters - ---------- - lines: Union[list[str], str - Lines to format - """ - if isinstance(lines, str): - lines = [lines] - lines = self.format_info_lines(lines) - for line in lines: - self.print_function(line) - - def print_green_stat(self, stat: Any, text: str) -> None: - """ - Print a statistic in green - - Parameters - ---------- - stat: Any - Statistic to print - text: str - Other text to follow statistic - """ - self.print_function(self.indent_string + f"{self.colorize(stat, 'green')} {text}") - - def print_yellow_stat(self, stat, text) -> None: - """ - Print a statistic in yellow - - Parameters - ---------- - stat: Any - Statistic to print - text: str - Other text to follow statistic - """ - self.print_function(self.indent_string + f"{self.colorize(stat, 'yellow')} {text}") - - def print_red_stat(self, stat, text) -> None: - """ - Print a statistic in red - - Parameters - ---------- - stat: Any - Statistic to print - text: str - Other text to follow statistic - """ - self.print_function(self.indent_string + f"{self.colorize(stat, 'red')} {text}") - - def colorize(self, text: Any, color: str) -> str: - """ - Colorize a string - - Parameters - ---------- - text: Any - Text to colorize - color: str - Colorama code or empty string to wrap the text - - Returns - ------- - str - Colorized string - """ - return f"{self.colors[color]}{text}{self.colors['reset']}" - - def print_block(self, block: dict, starting_level: int = 1) -> None: - """ - Print a configuration block - - Parameters - ---------- - block: dict - Configuration options to output - starting_level: int - Starting indentation level - """ - for k, v in block.items(): - value_color = None - key_color = None - value = "" - if isinstance(k, tuple): - k, key_color = k - - if isinstance(v, tuple): - value, value_color = v - elif not isinstance(v, dict): - value = v - self.print_information_line(k, value, key_color, value_color, starting_level) - if isinstance(v, dict): - self.print_block(v, starting_level=starting_level + 1) - self.print_function("") - - def print_config(self, configuration: MetaDict) -> None: - """ - Pretty print a configuration - - Parameters - ---------- - configuration: dict[str, Any] - Configuration to print - """ - for k, v in configuration.items(): - if "name" in v: - name = v["name"] - name_color = None - if isinstance(name, tuple): - name, name_color = name - self.print_information_line(k, name, value_color=name_color, level=0) - if "data" in v: - self.print_block(v["data"]) - - def print_information_line( - self, - key: str, - value: Any, - key_color: Optional[str] = None, - value_color: Optional[str] = None, - level: int = 1, - ) -> None: - """ - Pretty print a given configuration line - - Parameters - ---------- - key: str - Configuration key - value: Any - Configuration value - key_color: str - Key color - value_color: str - Value color - level: int - Indentation level - """ - if key_color is None: - key_color = "bright" - if value_color is None: - value_color = "cyan" - if isinstance(value, bool): - if value: - value_color = "green" - else: - value_color = "red" - if isinstance(value, (list, tuple, set)): - value = comma_join([self.colorize(x, value_color) for x in sorted(value)]) - else: - value = self.colorize(str(value), value_color) - indent = (" " * level) + "-" - subsequent_indent = " " * (level + 1) - if key: - key = f" {key}:" - subsequent_indent += " " * (len(key)) - - self.print_function( - ansiwrap.fill( - f"{self.colorize(key, key_color)} {value}", - width=shutil.get_terminal_size().columns, - initial_indent=indent, - subsequent_indent=subsequent_indent, - ) - ) - - def comma_join(sequence: List[Any]) -> str: """ Helper function to combine a list into a human-readable expression with commas and a diff --git a/montreal_forced_aligner/ivector/trainer.py b/montreal_forced_aligner/ivector/trainer.py index baa14bcc..ed4bab42 100644 --- a/montreal_forced_aligner/ivector/trainer.py +++ b/montreal_forced_aligner/ivector/trainer.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -import tqdm +from tqdm.rich import tqdm from montreal_forced_aligner.abc import MetaDict, ModelExporterMixin, TopLevelMfaWorker from montreal_forced_aligner.acoustic_modeling.base import AcousticModelTrainingMixin @@ -240,7 +240,7 @@ def gmm_gselect(self) -> None: begin = time.time() logger.info("Selecting gaussians...") arguments = self.gmm_gselect_arguments() - with tqdm.tqdm( + with tqdm( total=int(self.num_current_utterances / 10), disable=GLOBAL_CONFIG.quiet ) as pbar: for _ in run_kaldi_function(GmmGselectFunction, arguments, pbar.update): @@ -323,7 +323,7 @@ def acc_global_stats(self) -> None: logger.info("Accumulating global stats...") arguments = self.acc_global_stats_arguments() - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: for _ in run_kaldi_function(AccGlobalStatsFunction, arguments, pbar.update): pass @@ -539,7 +539,7 @@ def gauss_to_post(self) -> None: logger.info("Extracting posteriors...") arguments = self.gauss_to_post_arguments() - with tqdm.tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: for _ in run_kaldi_function(GaussToPostFunction, arguments, pbar.update): pass @@ -612,7 +612,7 @@ def acc_ivector_stats(self) -> None: logger.info("Accumulating ivector stats...") arguments = self.acc_ivector_stats_arguments() - with tqdm.tqdm(total=self.worker.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.worker.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: for _ in run_kaldi_function(AccIvectorStatsFunction, arguments, pbar.update): pass @@ -719,9 +719,9 @@ def compute_lda(self): lda_path = self.working_directory.joinpath("ivector_lda.mat") log_path = self.working_log_directory.joinpath("lda.log") utt2spk_path = os.path.join(self.corpus_output_directory, "utt2spk.scp") - with tqdm.tqdm( - total=self.worker.num_utterances, disable=GLOBAL_CONFIG.quiet - ) as pbar, mfa_open(log_path, "w") as log_file: + with tqdm(total=self.worker.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar, mfa_open( + log_path, "w" + ) as log_file: normalize_proc = subprocess.Popen( [ thirdparty_binary("ivector-normalize-length"), diff --git a/montreal_forced_aligner/language_modeling/trainer.py b/montreal_forced_aligner/language_modeling/trainer.py index 07bed55a..3ba087f7 100644 --- a/montreal_forced_aligner/language_modeling/trainer.py +++ b/montreal_forced_aligner/language_modeling/trainer.py @@ -11,11 +11,11 @@ from queue import Empty import sqlalchemy -import tqdm +from tqdm.rich import tqdm -from montreal_forced_aligner.abc import DatabaseMixin, TopLevelMfaWorker, TrainerMixin +from montreal_forced_aligner.abc import DatabaseMixin, MfaWorker, TopLevelMfaWorker, TrainerMixin from montreal_forced_aligner.config import GLOBAL_CONFIG -from montreal_forced_aligner.corpus.text_corpus import MfaWorker, TextCorpusMixin +from montreal_forced_aligner.corpus.text_corpus import TextCorpusMixin from montreal_forced_aligner.data import WordType, WorkflowType from montreal_forced_aligner.db import Dictionary, Utterance, Word from montreal_forced_aligner.dictionary.mixins import DictionaryMixin @@ -421,7 +421,7 @@ def train_large_lm(self) -> None: procs.append(p) p.start() count_paths.append(self.working_directory.joinpath(f"{j.id}.cnts")) - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: while True: try: result = return_queue.get(timeout=1) diff --git a/montreal_forced_aligner/models.py b/montreal_forced_aligner/models.py index 2d692ae1..1af3f9fa 100644 --- a/montreal_forced_aligner/models.py +++ b/montreal_forced_aligner/models.py @@ -16,6 +16,7 @@ import requests import yaml +from rich.pretty import pprint from montreal_forced_aligner.abc import MfaModel, ModelExporterMixin from montreal_forced_aligner.data import PhoneSetType @@ -26,7 +27,7 @@ PronunciationAcousticMismatchError, RemoteModelNotFoundError, ) -from montreal_forced_aligner.helper import EnhancedJSONEncoder, TerminalPrinter, mfa_open +from montreal_forced_aligner.helper import EnhancedJSONEncoder, mfa_open if TYPE_CHECKING: from dataclasses import dataclass @@ -241,11 +242,9 @@ def generate_path( def pretty_print(self) -> None: """ - Pretty print the archive's meta data using TerminalPrinter + Pretty print the archive's meta data using rich """ - printer = TerminalPrinter() - configuration_data = {"Archive": {"name": (self.name, "green"), "data": self.meta}} - printer.print_config(configuration_data) + pprint({"Archive": {"name": self.name, "data": self.meta}}) @property def meta(self) -> dict: @@ -527,17 +526,9 @@ def pretty_print(self) -> None: """ Prints the metadata information to the terminal """ - from .utils import get_mfa_version - printer = TerminalPrinter() - configuration_data = {"Acoustic model": {"name": (self.name, "green"), "data": {}}} - version_color = "green" - if self.meta["version"] != get_mfa_version(): - version_color = "red" - configuration_data["Acoustic model"]["data"]["Version"] = ( - self.meta["version"], - version_color, - ) + configuration_data = {"Acoustic model": {"name": self.name, "data": {}}} + configuration_data["Acoustic model"]["data"]["Version"] = (self.meta["version"],) if "citation" in self.meta: configuration_data["Acoustic model"]["data"]["Citation"] = self.meta["citation"] @@ -554,9 +545,9 @@ def pretty_print(self) -> None: if self.meta["phones"]: configuration_data["Acoustic model"]["data"]["Phones"] = self.meta["phones"] else: - configuration_data["Acoustic model"]["data"]["Phones"] = ("None found!", "red") + configuration_data["Acoustic model"]["data"]["Phones"] = "None found!" - printer.print_config(configuration_data) + pprint(configuration_data) def add_model(self, source: str) -> None: """ @@ -1233,12 +1224,11 @@ def add_meta_file(self, trainer: ModelExporterMixin) -> None: def pretty_print(self) -> None: """ - Pretty print the dictionary's metadata using TerminalPrinter + Pretty print the dictionary's metadata """ from montreal_forced_aligner.dictionary.multispeaker import MultispeakerDictionary - printer = TerminalPrinter() - configuration_data = {"Dictionary": {"name": (self.name, "green"), "data": self.meta}} + configuration_data = {"Dictionary": {"name": self.name, "data": self.meta}} temp_directory = self.dirname.joinpath("temp") if temp_directory.exists(): shutil.rmtree(temp_directory) @@ -1266,7 +1256,7 @@ def pretty_print(self) -> None: configuration_data["Dictionary"]["data"]["graphemes"] = sorted(graphemes) else: configuration_data["Dictionary"]["data"]["graphemes"] = f"{len(graphemes)} graphemes" - printer.print_config(configuration_data) + pprint(configuration_data) @classmethod def valid_extension(cls, filename: Path) -> bool: @@ -1418,7 +1408,6 @@ def __init__(self, token=None): if self.token is not None: self.token = environment_token self.synced_remote = False - self.printer = TerminalPrinter() self._cache_info = {} self.refresh_local() @@ -1534,28 +1523,19 @@ def print_local_models(self, model_type: typing.Optional[str] = None) -> None: """ self.refresh_local() if model_type is None: - self.printer.print_information_line("Available local models", "", level=0) + logger.info("Available local models") + data = {} for model_type, model_class in MODEL_TYPES.items(): - names = model_class.get_available_models() - if names: - self.printer.print_information_line(model_type, names, value_color="green") - else: - self.printer.print_information_line( - model_type, "No models found", value_color="yellow" - ) + data[model_type] = model_class.get_available_models() + pprint(data) else: - self.printer.print_information_line( - f"Available local {model_type} models", "", level=0 - ) + logger.info(f"Available local {model_type} models") model_class = MODEL_TYPES[model_type] names = model_class.get_available_models() if names: - for name in names: - self.printer.print_information_line("", name, value_color="green", level=1) + pprint(names) else: - self.printer.print_information_line( - "", "No models found", value_color="yellow", level=1 - ) + logger.error("No models found") def print_remote_models(self, model_type: typing.Optional[str] = None) -> None: """ @@ -1569,27 +1549,18 @@ def print_remote_models(self, model_type: typing.Optional[str] = None) -> None: if not self.synced_remote: self.refresh_remote() if model_type is None: - self.printer.print_information_line("Available models for download", "", level=0) + logger.info("Available models for download") + data = {} for model_type, release_data in self.remote_models.items(): - names = sorted(release_data.keys()) - if names: - self.printer.print_information_line(model_type, names, value_color="green") - else: - self.printer.print_information_line( - model_type, "No models found", value_color="red" - ) + data[model_type] = sorted(release_data.keys()) + pprint(data) else: - self.printer.print_information_line( - f"Available {model_type} models for download", "", level=0 - ) + logger.info(f"Available {model_type} models for download") names = sorted(self.remote_models[model_type].keys()) if names: - for name in names: - self.printer.print_information_line("", name, value_color="green", level=1) + pprint(names) else: - self.printer.print_information_line( - "", "No models found", value_color="yellow", level=1 - ) + logger.error("No models found") def download_model( self, model_type: str, model_name=typing.Optional[str], ignore_cache=False diff --git a/montreal_forced_aligner/tokenization/tokenizer.py b/montreal_forced_aligner/tokenization/tokenizer.py index 91a83cf9..9a547eb8 100644 --- a/montreal_forced_aligner/tokenization/tokenizer.py +++ b/montreal_forced_aligner/tokenization/tokenizer.py @@ -13,17 +13,17 @@ import pynini import pywrapfst import sqlalchemy -import tqdm from praatio import textgrid from pynini import Fst from pynini.lib import rewrite from pywrapfst import SymbolTable from sqlalchemy.orm import joinedload, selectinload +from tqdm.rich import tqdm from montreal_forced_aligner.abc import KaldiFunction, TopLevelMfaWorker from montreal_forced_aligner.alignment.multiprocessing import construct_output_path from montreal_forced_aligner.config import GLOBAL_CONFIG -from montreal_forced_aligner.corpus.text_corpus import TextCorpusMixin +from montreal_forced_aligner.corpus.acoustic_corpus import AcousticCorpusMixin from montreal_forced_aligner.data import MfaArguments, TextgridFormats from montreal_forced_aligner.db import File, Utterance, bulk_update from montreal_forced_aligner.dictionary.mixins import DictionaryMixin @@ -207,7 +207,7 @@ def _run(self) -> typing.Generator: yield u_id, tokenized_text -class CorpusTokenizer(TextCorpusMixin, TopLevelMfaWorker, DictionaryMixin): +class CorpusTokenizer(AcousticCorpusMixin, TopLevelMfaWorker, DictionaryMixin): """ Top-level worker for generating pronunciations from a corpus and a Pynini tokenizer model """ @@ -322,7 +322,7 @@ def tokenize_utterances(self) -> None: self.setup() logger.info("Tokenizing utterances...") args = self.tokenize_arguments() - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: update_mapping = [] for utt_id, tokenized in run_kaldi_function(TokenizerFunction, args, pbar.update): update_mapping.append({"id": utt_id, "text": tokenized}) @@ -388,7 +388,7 @@ def tokenize_utterances(self) -> typing.Dict[str, str]: logger.info("Tokenizing utterances...") to_return = {} if num_utterances < 30 or GLOBAL_CONFIG.num_jobs == 1: - with tqdm.tqdm(total=num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: for utterance in self.utterances_to_tokenize: pbar.update(1) result = self.rewriter(utterance) @@ -410,7 +410,7 @@ def tokenize_utterances(self) -> typing.Dict[str, str]: ) procs.append(p) p.start() - with tqdm.tqdm(total=num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: while True: try: utterance, result = return_queue.get(timeout=1) diff --git a/montreal_forced_aligner/tokenization/trainer.py b/montreal_forced_aligner/tokenization/trainer.py index 5aba586e..0e735b79 100644 --- a/montreal_forced_aligner/tokenization/trainer.py +++ b/montreal_forced_aligner/tokenization/trainer.py @@ -13,7 +13,7 @@ from montreal_forced_aligner.abc import MetaDict, TopLevelMfaWorker from montreal_forced_aligner.config import GLOBAL_CONFIG -from montreal_forced_aligner.corpus.text_corpus import TextCorpusMixin +from montreal_forced_aligner.corpus.acoustic_corpus import AcousticCorpusMixin from montreal_forced_aligner.data import WorkflowType from montreal_forced_aligner.db import M2M2Job, M2MSymbol, Utterance from montreal_forced_aligner.dictionary.mixins import DictionaryMixin @@ -194,7 +194,7 @@ def run(self) -> None: del far_writer -class TokenizerMixin(TextCorpusMixin, G2PTrainer, DictionaryMixin, TopLevelMfaWorker): +class TokenizerMixin(AcousticCorpusMixin, G2PTrainer, DictionaryMixin, TopLevelMfaWorker): def __init__(self, **kwargs): super().__init__(**kwargs) self.training_graphemes = set() diff --git a/montreal_forced_aligner/transcription/transcriber.py b/montreal_forced_aligner/transcription/transcriber.py index ef15902c..ef755245 100644 --- a/montreal_forced_aligner/transcription/transcriber.py +++ b/montreal_forced_aligner/transcription/transcriber.py @@ -19,9 +19,9 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple import pywrapfst -import tqdm from praatio import textgrid from sqlalchemy.orm import joinedload, selectinload +from tqdm.rich import tqdm from montreal_forced_aligner.abc import TopLevelMfaWorker from montreal_forced_aligner.alignment.base import CorpusAligner @@ -205,7 +205,7 @@ def train_speaker_lms(self) -> None: os.makedirs(log_directory, exist_ok=True) logger.info("Compiling per speaker biased language models...") arguments = self.train_speaker_lm_arguments() - with tqdm.tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() @@ -276,7 +276,7 @@ def lm_rescore(self) -> None: p = KaldiProcessWorker(i, return_queue, function, stopped) procs.append(p) p.start() - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: while True: try: result = return_queue.get(timeout=1) @@ -304,7 +304,7 @@ def lm_rescore(self) -> None: else: for args in self.lm_rescore_arguments(): function = LmRescoreFunction(args) - with tqdm.tqdm(total=GLOBAL_CONFIG.num_jobs, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=GLOBAL_CONFIG.num_jobs, disable=GLOBAL_CONFIG.quiet) as pbar: for succeeded, failed in function.run(): if failed: logger.warning("Some lattices failed to be rescored") @@ -332,7 +332,7 @@ def carpa_lm_rescore(self) -> None: p = KaldiProcessWorker(i, return_queue, function, stopped) procs.append(p) p.start() - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: while True: try: result = return_queue.get(timeout=1) @@ -360,7 +360,7 @@ def carpa_lm_rescore(self) -> None: else: for args in self.carpa_lm_rescore_arguments(): function = CarpaLmRescoreFunction(args) - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: for succeeded, failed in function.run(): if failed: logger.warning("Some lattices failed to be rescored") @@ -387,7 +387,7 @@ def train_phone_lm(self): procs = [] count_paths = [] allowed_bigrams = collections.defaultdict(set) - with self.session() as session, tqdm.tqdm( + with self.session() as session, tqdm( total=self.num_current_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar: @@ -878,7 +878,7 @@ def decode(self) -> None: Arguments for function """ logger.info("Generating lattices...") - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: workflow = self.current_workflow arguments = self.decode_arguments(workflow.workflow_type) log_likelihood_sum = 0 @@ -912,7 +912,7 @@ def calc_initial_fmllr(self) -> None: """ logger.info("Calculating initial fMLLR transforms...") sum_errors = 0 - with tqdm.tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() @@ -966,7 +966,7 @@ def lat_gen_fmllr(self) -> None: logger.info("Regenerating lattices with fMLLR transforms...") workflow = self.current_workflow arguments = self.lat_gen_fmllr_arguments(workflow.workflow_type) - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar, mfa_open( + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar, mfa_open( self.working_log_directory.joinpath("lat_gen_fmllr_log_like.csv"), "w", encoding="utf8", @@ -1025,7 +1025,7 @@ def calc_final_fmllr(self) -> None: """ logger.info("Calculating final fMLLR transforms...") sum_errors = 0 - with tqdm.tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_speakers, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() @@ -1078,7 +1078,7 @@ def fmllr_rescore(self) -> None: """ logger.info("Rescoring fMLLR lattices with final transform...") sum_errors = 0 - with tqdm.tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=self.num_utterances, disable=GLOBAL_CONFIG.quiet) as pbar: if GLOBAL_CONFIG.use_mp: error_dict = {} return_queue = mp.Queue() @@ -1549,7 +1549,7 @@ def create_hclgs(self) -> None: p = KaldiProcessWorker(i, return_queue, function, stopped) procs.append(p) p.start() - with tqdm.tqdm(total=len(dict_arguments) * 7, disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=len(dict_arguments) * 7, disable=GLOBAL_CONFIG.quiet) as pbar: while True: try: result = return_queue.get(timeout=1) @@ -1582,7 +1582,7 @@ def create_hclgs(self) -> None: else: for args in dict_arguments: function = CreateHclgFunction(args) - with tqdm.tqdm(total=len(dict_arguments), disable=GLOBAL_CONFIG.quiet) as pbar: + with tqdm(total=len(dict_arguments), disable=GLOBAL_CONFIG.quiet) as pbar: for result in function.run(): if not isinstance(result, tuple): pbar.update(1) diff --git a/montreal_forced_aligner/vad/segmenter.py b/montreal_forced_aligner/vad/segmenter.py index b5d93d75..cd8df2e8 100644 --- a/montreal_forced_aligner/vad/segmenter.py +++ b/montreal_forced_aligner/vad/segmenter.py @@ -13,8 +13,8 @@ from typing import Dict, List, Optional import sqlalchemy -import tqdm from sqlalchemy.orm import joinedload, selectinload +from tqdm.rich import tqdm from montreal_forced_aligner.abc import FileExporterMixin, MetaDict, TopLevelMfaWorker from montreal_forced_aligner.config import GLOBAL_CONFIG @@ -230,7 +230,7 @@ def segment_vad_speechbrain(self) -> None: new_utts = [] kwargs = self.segmentation_options kwargs.pop("frame_shift") - with tqdm.tqdm( + with tqdm( total=self.num_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar, self.session() as session: utt_index = session.query(sqlalchemy.func.max(Utterance.id)).scalar() @@ -293,7 +293,7 @@ def segment_vad_mfa(self) -> None: old_utts = set() new_utts = [] - with tqdm.tqdm( + with tqdm( total=self.num_utterances, disable=GLOBAL_CONFIG.quiet ) as pbar, self.session() as session: utterances = session.query( diff --git a/montreal_forced_aligner/validation/corpus_validator.py b/montreal_forced_aligner/validation/corpus_validator.py index afb17002..f1774d7f 100644 --- a/montreal_forced_aligner/validation/corpus_validator.py +++ b/montreal_forced_aligner/validation/corpus_validator.py @@ -22,12 +22,7 @@ from montreal_forced_aligner.data import WorkflowType from montreal_forced_aligner.db import Corpus, File, SoundFile, Speaker, TextFile, Utterance from montreal_forced_aligner.exceptions import ConfigError, KaldiProcessingError -from montreal_forced_aligner.helper import ( - TerminalPrinter, - comma_join, - load_configuration, - mfa_open, -) +from montreal_forced_aligner.helper import comma_join, load_configuration, mfa_open from montreal_forced_aligner.utils import log_kaldi_errors, run_mp, run_non_mp if TYPE_CHECKING: @@ -59,10 +54,6 @@ class ValidationMixin: :class:`~montreal_forced_aligner.alignment.base.CorpusAligner` For corpus, dictionary, and alignment parameters - Attributes - ---------- - printer: TerminalPrinter - Printer for output messages """ def __init__( @@ -80,7 +71,6 @@ def __init__( self.target_num_ngrams = target_num_ngrams self.order = order self.method = method - self.printer = TerminalPrinter(print_function=logger.info) @property def working_log_directory(self) -> str: @@ -107,25 +97,21 @@ def analyze_setup(self) -> None: ignored_count += len(self.decode_error_files) logger.debug(f"Ignored count calculation took {time.time() - begin:.3f} seconds") - self.printer.print_header("Corpus") - self.printer.print_green_stat(sound_file_count, "sound files") - self.printer.print_green_stat(text_file_count, "text files") + logger.info("Corpus") + logger.info(f"{sound_file_count} sound files") + logger.info(f"{text_file_count} text files") if len(self.no_transcription_files): - self.printer.print_yellow_stat( - len(self.no_transcription_files), - "sound files without corresponding transcriptions", + logger.warning( + f"{len(self.no_transcription_files)} sound files without corresponding transcriptions", ) if len(self.decode_error_files): - self.printer.print_red_stat(len(self.decode_error_files), "read errors for lab files") + logger.error(f"{len(self.decode_error_files)} read errors for lab files") if len(self.textgrid_read_errors): - self.printer.print_red_stat( - len(self.textgrid_read_errors), "read errors for TextGrid files" - ) + logger.error(f"{len(self.textgrid_read_errors)} read errors for TextGrid files") - self.printer.print_green_stat(self.num_speakers, "speakers") - self.printer.print_green_stat(self.num_utterances, "utterances") - self.printer.print_green_stat(total_duration, "seconds total duration") - print() + logger.info(f"{self.num_speakers} speakers") + logger.info(f"{self.num_utterances} utterances") + logger.info(f"{total_duration} seconds total duration") self.analyze_wav_errors() self.analyze_missing_features() self.analyze_files_with_no_transcription() @@ -136,14 +122,14 @@ def analyze_setup(self) -> None: if len(self.textgrid_read_errors): self.analyze_textgrid_read_errors() - self.printer.print_header("Dictionary") + logger.info("Dictionary") self.analyze_oovs() def analyze_oovs(self) -> None: """ Analyzes OOVs in the corpus and constructs message """ - self.printer.print_sub_header("Out of vocabulary words") + logger.info("Out of vocabulary words") output_dir = self.output_directory oov_path = os.path.join(output_dir, "oovs_found.txt") utterance_oov_path = os.path.join(output_dir, "utterance_oovs.txt") @@ -173,33 +159,24 @@ def analyze_oovs(self) -> None: self.oovs_found.update(oovs) if self.oovs_found: self.save_oovs_found(self.output_directory) - self.printer.print_yellow_stat(len(self.oovs_found), "OOV word types") - self.printer.print_yellow_stat(total_instances, "total OOV tokens") - lines = [ - "", - "For a full list of the word types, please see:", - "", - self.printer.indent_string + self.printer.colorize(oov_path, "bright"), - "", - "For a by-utterance breakdown of missing words, see:", - "", - self.printer.indent_string + self.printer.colorize(utterance_oov_path, "bright"), - "", - ] - self.printer.print_info_lines(lines) + logger.warning(f"{len(self.oovs_found)} OOV word types") + logger.warning(f"{total_instances}total OOV tokens") + logger.warning( + f"For a full list of the word types, please see: {oov_path}. " + f"For a by-utterance breakdown of missing words, see: {utterance_oov_path}" + ) else: - self.printer.print_info_lines( - f"There were {self.printer.colorize('no', 'yellow')} missing words from the dictionary. If you plan on using the a model trained " + logger.info( + "There were no missing words from the dictionary. If you plan on using the a model trained " "on this dataset to align other datasets in the future, it is recommended that there be at " "least some missing words." ) - self.printer.print_end_section() def analyze_wav_errors(self) -> None: """ Analyzes any sound file issues in the corpus and constructs message """ - self.printer.print_sub_header("Sound file read errors") + logger.info("Sound file read errors") output_dir = self.output_directory wav_read_errors = self.sound_file_errors @@ -209,25 +186,20 @@ def analyze_wav_errors(self) -> None: for p in wav_read_errors: f.write(f"{p}\n") - self.printer.print_info_lines( - f"There were {self.printer.colorize(len(wav_read_errors), 'red')} issues reading sound files. " - f"Please see {self.printer.colorize(path, 'bright')} for a list." + logger.error( + f"There were {len(wav_read_errors)} issues reading sound files. " + f"Please see {path} for a list." ) else: - self.printer.print_info_lines( - f"There were {self.printer.colorize('no', 'green')} issues reading sound files." - ) - - self.printer.print_end_section() + logger.info("There were no issues reading sound files.") def analyze_missing_features(self) -> None: """ Analyzes issues in feature generation in the corpus and constructs message """ - self.printer.print_sub_header("Feature generation") + logger.info("Feature generation") if self.ignore_acoustics: - self.printer.print_info_lines("Acoustic feature generation was skipped.") - self.printer.print_end_section() + logger.info("Acoustic feature generation was skipped.") return output_dir = self.output_directory with self.session() as session: @@ -243,66 +215,57 @@ def analyze_missing_features(self) -> None: f.write(f"{relative_path + '/' + file_name},{begin},{end}\n") - self.printer.print_info_lines( - f"There were {self.printer.colorize(utterances.count(), 'red')} utterances missing features. " - f"Please see {self.printer.colorize(path, 'bright')} for a list." + logger.error( + f"There were {utterances.count()} utterances missing features. " + f"Please see {path} for a list." ) else: - self.printer.print_info_lines( - f"There were {self.printer.colorize('no', 'green')} utterances missing features." - ) - self.printer.print_end_section() + logger.info("There were no utterances missing features.") def analyze_files_with_no_transcription(self) -> None: """ Analyzes issues with sound files that have no transcription files in the corpus and constructs message """ - self.printer.print_sub_header("Files without transcriptions") + logger.info("Files without transcriptions") output_dir = self.output_directory if self.no_transcription_files: path = os.path.join(output_dir, "missing_transcriptions.csv") with mfa_open(path, "w") as f: for file_path in self.no_transcription_files: f.write(f"{file_path}\n") - self.printer.print_info_lines( - f"There were {self.printer.colorize(len(self.no_transcription_files), 'red')} sound files missing transcriptions. " - f"Please see {self.printer.colorize(path, 'bright')} for a list." + logger.error( + f"There were {len(self.no_transcription_files)} sound files missing transcriptions." ) + logger.error(f"Please see {path} for a list.") else: - self.printer.print_info_lines( - f"There were {self.printer.colorize('no', 'green')} sound files missing transcriptions." - ) - self.printer.print_end_section() + logger.info("There were no sound files missing transcriptions.") def analyze_transcriptions_with_no_wavs(self) -> None: """ Analyzes issues with transcription that have no sound files in the corpus and constructs message """ - self.printer.print_sub_header("Transcriptions without sound files") + logger.info("Transcriptions without sound files") output_dir = self.output_directory if self.transcriptions_without_wavs: path = os.path.join(output_dir, "transcriptions_missing_sound_files.csv") with mfa_open(path, "w") as f: for file_path in self.transcriptions_without_wavs: f.write(f"{file_path}\n") - self.printer.print_info_lines( - f"There were {self.printer.colorize(len(self.transcriptions_without_wavs), 'red')} transcription files missing sound files. " - f"Please see {self.printer.colorize(path, 'bright')} for a list." + logger.error( + f"There were {len(self.transcriptions_without_wavs)} transcription files missing sound files. " + f"Please see {path} for a list." ) else: - self.printer.print_info_lines( - f"There were {self.printer.colorize('no', 'green')} transcription files missing sound files." - ) - self.printer.print_end_section() + logger.info("There were no transcription files missing sound files.") def analyze_textgrid_read_errors(self) -> None: """ Analyzes issues with reading TextGrid files in the corpus and constructs message """ - self.printer.print_sub_header("TextGrid read errors") + logger.info("TextGrid read errors") output_dir = self.output_directory if self.textgrid_read_errors: path = os.path.join(output_dir, "textgrid_read_errors.txt") @@ -311,43 +274,31 @@ def analyze_textgrid_read_errors(self) -> None: f.write( f"The TextGrid file {e.file_name} gave the following error on load:\n\n{e}\n\n\n" ) - self.printer.print_info_lines( - [ - f"There were {self.printer.colorize(len(self.textgrid_read_errors), 'red')} TextGrid files that could not be loaded. " - "For details, please see:", - "", - self.printer.indent_string + self.printer.colorize(path, "bright"), - ] + logger.error( + f"There were {len(self.textgrid_read_errors)} TextGrid files that could not be loaded. " + f"For details, please see: {path}", ) else: - self.printer.print_info_lines( - f"There were {self.printer.colorize('no', 'green')} issues reading TextGrids." - ) - - self.printer.print_end_section() + logger.info("There were no issues reading TextGrids.") def analyze_unreadable_text_files(self) -> None: """ Analyzes issues with reading text files in the corpus and constructs message """ - self.printer.print_sub_header("Text file read errors") + logger.info("Text file read errors") output_dir = self.output_directory if self.decode_error_files: path = os.path.join(output_dir, "utf8_read_errors.csv") with mfa_open(path, "w") as f: for file_path in self.decode_error_files: f.write(f"{file_path}\n") - self.printer.print_info_lines( - f"There were {self.printer.colorize(len(self.decode_error_files), 'red')} text files that could not be read. " - f"Please see {self.printer.colorize(path, 'bright')} for a list." + logger.error( + f"There were {len(self.decode_error_files)} text files that could not be read. " + f"Please see {path} for a list." ) else: - self.printer.print_info_lines( - f"There were {self.printer.colorize('no', 'green')} issues reading text files." - ) - - self.printer.print_end_section() + logger.info("There were no issues reading text files.") def compile_information(self) -> None: """ @@ -392,29 +343,22 @@ def compile_information(self) -> None: average_logdet_frames += data["logdet_frames"] average_logdet_sum += data["logdet"] * data["logdet_frames"] - self.printer.print_header("Alignment") + logger.info("Alignment") if not avg_like_frames: - logger.debug( - "No utterances were aligned, this likely indicates serious problems with the aligner." - ) - self.printer.print_red_stat(0, f"of {self.num_utterances} utterances were aligned") + logger.error(f"0 of {self.num_utterances} utterances were aligned") else: if too_short_count: - self.printer.print_red_stat( - too_short_count, "utterances were too short to be aligned" + logger.error( + too_short_count, f"{too_short_count} utterances were too short to be aligned" ) else: - self.printer.print_green_stat(0, "utterances were too short to be aligned") + logger.info("0 utterances were too short to be aligned") if beam_too_narrow_count: - logger.debug( - f"There were {beam_too_narrow_count} utterances that could not be aligned with " - f"the current beam settings." - ) - self.printer.print_yellow_stat( - beam_too_narrow_count, "utterances that need a larger beam to align" + logger.warning( + f"{beam_too_narrow_count} utterances that need a larger beam to align" ) else: - self.printer.print_green_stat(0, "utterances that need a larger beam to align") + logger.info("0 utterances that need a larger beam to align") num_utterances = self.num_utterances with self.session() as session: @@ -433,18 +377,13 @@ def compile_information(self) -> None: f.write( f"{u.file.name},{u.begin},{u.end},{u.duration},{utt_length_words}\n" ) - self.printer.print_info_lines( - [ - f"There were {self.printer.colorize(unaligned_count, 'red')} unaligned utterances out of {self.printer.colorize(self.num_utterances, 'bright')} after initial training. " - f"For details, please see:", - "", - self.printer.indent_string + self.printer.colorize(path, "bright"), - ] + logger.error( + f"There were {unaligned_count} unaligned utterances out of {self.num_utterances} after initial training. " + f"For details, please see: {path}", ) - - self.printer.print_green_stat( - num_utterances - beam_too_narrow_count - too_short_count, - "utterances were successfully aligned", + successful_utterances = num_utterances - beam_too_narrow_count - too_short_count + logger.info( + f"{successful_utterances} utterances were successfully aligned", ) average_log_like = avg_like_sum / avg_like_frames if average_logdet_sum: @@ -462,39 +401,38 @@ def test_utterance_transcriptions(self) -> None: :class:`~montreal_forced_aligner.exceptions.KaldiProcessingError` If there were any errors in running Kaldi binaries """ - logger.info("Checking utterance transcriptions...") try: self.train_speaker_lms() self.transcribe(WorkflowType.per_speaker_transcription) - self.printer.print_header("Test transcriptions") + logger.info("Test transcriptions") ser, wer, cer = self.compute_wer() if ser < 0.3: - self.printer.print_green_stat(f"{ser*100:.2f}%", "sentence error rate") + logger.info(f"{ser*100:.2f}% sentence error rate") elif ser < 0.8: - self.printer.print_yellow_stat(f"{ser*100:.2f}%", "sentence error rate") + logger.warning(f"{ser*100:.2f}% sentence error rate") else: - self.printer.print_red_stat(f"{ser*100:.2f}%", "sentence error rate") + logger.error(f"{ser*100:.2f}% sentence error rate") if wer < 0.25: - self.printer.print_green_stat(f"{wer*100:.2f}%", "word error rate") + logger.info(f"{wer*100:.2f}% word error rate") elif wer < 0.75: - self.printer.print_yellow_stat(f"{wer*100:.2f}%", "word error rate") + logger.warning(f"{wer*100:.2f}% word error rate") else: - self.printer.print_red_stat(f"{wer*100:.2f}%", "word error rate") + logger.error(f"{wer*100:.2f}% word error rate") if cer < 0.25: - self.printer.print_green_stat(f"{cer*100:.2f}%", "character error rate") + logger.info(f"{cer*100:.2f}% character error rate") elif cer < 0.75: - self.printer.print_yellow_stat(f"{cer*100:.2f}%", "character error rate") + logger.warning(f"{cer*100:.2f}% character error rate") else: - self.printer.print_red_stat(f"{cer*100:.2f}%", "character error rate") + logger.error(f"{cer*100:.2f}% character error rate") self.save_transcription_evaluation(self.output_directory) out_path = os.path.join(self.output_directory, "transcription_evaluation.csv") - print(f"See {self.printer.colorize(out_path, 'bright')} for more details.") + logger.info(f"See {out_path} for more details.") except Exception as e: if isinstance(e, KaldiProcessingError): @@ -658,9 +596,9 @@ def validate(self) -> None: self.analyze_setup() logger.debug(f"Setup took {time.time() - begin:.3f} seconds") if self.ignore_acoustics: - self.printer.print_info_lines("Skipping test alignments.") + logger.info("Skipping test alignments.") return - self.printer.print_header("Training") + logger.info("Training") self.train() if self.test_transcriptions: self.test_utterance_transcriptions() @@ -756,26 +694,13 @@ def validate(self) -> None: def analyze_missing_phones(self) -> None: """Analyzes dictionary and acoustic model for phones in the dictionary that don't have acoustic models""" - self.printer.print_sub_header("Acoustic model compatibility") + logger.info("Acoustic model compatibility") if self.excluded_pronunciation_count: - self.printer.print_yellow_stat( - len(self.excluded_phones), "phones not in acoustic model" - ) - self.printer.print_yellow_stat( - self.excluded_pronunciation_count, "ignored pronunciations" - ) + logger.warning(len(self.excluded_phones), "phones not in acoustic model") + logger.warning(self.excluded_pronunciation_count, "ignored pronunciations") - phone_string = [self.printer.colorize(x, "red") for x in sorted(self.excluded_phones)] - self.printer.print_info_lines( - [ - "", - "Phones missing acoustic models:", - "", - self.printer.indent_string + comma_join(phone_string), - ] + logger.error( + f"Phones missing acoustic models: {comma_join(sorted(self.excluded_phones))}" ) else: - self.printer.print_info_lines( - f"There were {self.printer.colorize('no', 'green')} phones in the dictionary without acoustic models." - ) - self.printer.print_end_section() + logger.info("There were no phones in the dictionary without acoustic models.") diff --git a/requirements.txt b/requirements.txt index 29df5501..2ad786a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,8 +3,8 @@ tqdm pyyaml librosa requests -colorama biopython dataclassy sqlalchemy -ansiwrap +rich +rich-click diff --git a/rtd_environment.yml b/rtd_environment.yml index 513d91fe..cf330d92 100644 --- a/rtd_environment.yml +++ b/rtd_environment.yml @@ -6,8 +6,6 @@ dependencies: - librosa - tqdm - requests - - colorama - - ansiwrap - pyyaml - praatio=6.0.0 - dataclassy @@ -35,6 +33,8 @@ dependencies: - kneed - matplotlib - seaborn + - rich + - rich-click - pip: - sphinx-needs - sphinxcontrib-plantuml diff --git a/setup.cfg b/setup.cfg index 31bca5f5..33821bf1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,12 +36,9 @@ keywords = phonology [options] packages = find: install_requires = - ansiwrap biopython biopython<=1.79 click - click - colorama dataclassy kneed librosa @@ -51,6 +48,8 @@ install_requires = praatio>=5.0 pyyaml requests + rich + rich-click scikit-learn seaborn sqlalchemy>=1.4 diff --git a/tests/conftest.py b/tests/conftest.py index 68d196f4..e319b5ba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -597,6 +597,27 @@ def swedish_dir(corpus_root_dir, wav_dir, lab_dir): return path +@pytest.fixture() +def japanese_cv_dir(corpus_root_dir, wav_dir, lab_dir): + path = corpus_root_dir.joinpath("test_japanese_cv") + path.mkdir(parents=True, exist_ok=True) + names = [ + ( + "02a8841a00d7624", + [ + "common_voice_ja_24511055", + ], + ) + ] + for s, files in names: + s_dir = path.joinpath(s) + s_dir.mkdir(parents=True, exist_ok=True) + for name in files: + shutil.copyfile(wav_dir.joinpath(name + ".mp3"), s_dir.joinpath(name + ".mp3")) + shutil.copyfile(lab_dir.joinpath(name + ".lab"), s_dir.joinpath(name + ".lab")) + return path + + @pytest.fixture() def basic_corpus_txt_dir(corpus_root_dir, wav_dir, lab_dir): path = corpus_root_dir.joinpath("test_basic_txt") diff --git a/tests/data/lab/common_voice_ja_24511055.lab b/tests/data/lab/common_voice_ja_24511055.lab new file mode 100644 index 00000000..9df88fa4 --- /dev/null +++ b/tests/data/lab/common_voice_ja_24511055.lab @@ -0,0 +1 @@ +真っ昼間なのにキャンプの外れの電柱に電球がともっていた diff --git a/tests/data/wav/common_voice_ja_24511055.mp3 b/tests/data/wav/common_voice_ja_24511055.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..c466769cd220f7bdd8249f104014b46882457e99 GIT binary patch literal 39141 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